Compare commits

..

No commits in common. "19fae6d7fe84ec3d359be448f5169e5df234c154" and "0f4f4bcf1505726c80afa60f2d500bf0edc4bb1e" have entirely different histories.

13 changed files with 497 additions and 874 deletions

BIN
logs.txt

Binary file not shown.

10
src/MenuItem.interface.ts Normal file
View File

@ -0,0 +1,10 @@
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, Delete } from '@nestjs/common'; import { Controller, Get, Post, Put, Body, Param, Headers, HttpException, HttpStatus } from '@nestjs/common';
import { MenuService } from './menu.service'; import { MenuService } from './menu.service';
import { MenuItem } from './menu.interface'; import { MenuItem } from './menu.interface';
@ -44,72 +44,24 @@ 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>
) { ) {
try {
const updatedItem = await this.menuService.updateMenuItem(id, update); const updatedItem = await this.menuService.updateMenuItem(id, update);
return updatedItem; 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,5 +11,4 @@ export interface MenuItem {
max: number; max: number;
status: number; status: number;
}>; }>;
hidden?: boolean;
} }

View File

@ -10,28 +10,29 @@ 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');
private readonly userOverridesPath = path.join(process.cwd(), 'data', 'user_menu_overrides.json'); async saveMenuToFile(): Promise<void> {
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.applyUserOverrides(baseMenu); const freshMenu = this.applyOverrides(baseMenu, overrides);
this.menuCache = freshMenu; this.menuCache = freshMenu;
this.lastModified = new Date(); this.lastModified = new Date();
@ -47,75 +48,33 @@ export class MenuService {
return !this.lastModified || new Date(ifModifiedSince) < this.lastModified; return !this.lastModified || new Date(ifModifiedSince) < this.lastModified;
} }
async hideMenuItem(id: string): Promise<void> { private applyOverrides(menu: MenuItem, overrides: Partial<MenuItem>[]): MenuItem {
this.userOverrides.set(id, { id, hidden: true }); const overrideMap = new Map(overrides.map(o => [o.id, o]));
await this.saveUserOverrides();
this.invalidateCache();
}
private applyUserOverrides(menu: MenuItem): MenuItem { const apply = (item: MenuItem): MenuItem => {
const apply = (item: MenuItem): MenuItem | null => { const override = overrideMap.get(item.id);
const override = this.userOverrides.get(item.id); const updated = override ? { ...item, ...override } : item;
if (override?.hidden) {
return null;
}
const updated: MenuItem = {
...item,
...override,
hidden: undefined
};
if (updated.items) { if (updated.items) {
const processedItems = updated.items updated.items = updated.items.map(apply);
.map(apply)
.filter((item): item is MenuItem => item !== null);
updated.items = processedItems.length > 0 ? processedItems : undefined;
} }
return updated; return updated;
}; };
const result = apply(menu); return apply(menu);
return result || { title: menu.title, id: menu.id, items: [] };
} }
private async loadUserOverrides(): Promise<void> { private async loadOverrides(): Promise<Partial<MenuItem>[]> {
try { try {
const content = await fs.readFile(this.userOverridesPath, 'utf-8'); const content = await fs.readFile(this.menuOverridesPath, '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) {
this.userOverrides = new Map(); return [];
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: "ЗВКС",
@ -207,12 +166,9 @@ 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: deviceName, title: `Graviton S2082I (${device})`,
items: moduleItems, items: moduleItems,
isDynamic: true isDynamic: true
}; };
@ -231,175 +187,41 @@ export class MenuService {
private normalizeIdPart(part: string): string { private normalizeIdPart(part: string): string {
return part return part
.replace(/\$/g, '_') .replace(/\$/g, '_')
.replace(/,/g, '_') .replace(/[^a-zA-Z0-9-_]/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 = this.normalizeSourceId(labels.source_id); const sourceId = labels.source_id;
if (sourceId.includes(', complex') || sourceId.includes(', integration')) {
const [modulePart, folderType] = sourceId.split(', ').map(s => s.trim());
let displayName = modulePart;
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; let displayName = sourceId;
if (sourceId.startsWith('module$')) { if (sourceId.startsWith('module$')) {
displayName = `Module ${sourceId.split('$')[1]}`; displayName = `Module ${sourceId.split('$')[1]}`;
} else if (sourceId.startsWith('port$')) { } else if (sourceId.startsWith('port$')) {
displayName = `Port ${sourceId.split('$')[1]}`; displayName = `Port ${sourceId.split('$')[1]}`;
} }
modules.set(sourceId, displayName); modules.set(sourceId, displayName);
} }
}
}); });
const moduleItems = Array.from(modules.entries()).map( const modulePromises = Array.from(modules.entries()).map(
async ([sourceId, displayName]) => ({ async ([sourceId, displayName]) => ({
id: `module_${device}_${this.normalizeIdPart(sourceId)}`, id: `module_${device}_${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
}) })
); );
const specialFolderItems = Array.from(specialFolders.entries()).map( return Promise.all(modulePromises);
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(
@ -409,33 +231,13 @@ 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(
const normModule = this.normalizeSourceId(module); ({ labels }) => labels.device === device && labels.source_id === 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(normModule); const safeModule = this.normalizeIdPart(module);
return Array.from(uniqueMetrics).map(metric => { return Array.from(uniqueMetrics).map(metric => {
const description = metadataMap.get(metric) || metric; const description = metadataMap.get(metric) || metric;
@ -448,13 +250,13 @@ export class MenuService {
metric, metric,
filters: { filters: {
device, device,
source_id: normModule source_id: module
}, },
ranges: metricRanges, ranges: metricRanges,
isDynamic: true, isDynamic: true,
meta: { meta: {
originalDevice: device, originalDevice: device,
originalModule: normModule originalModule: module
} }
}; };
}); });
@ -481,20 +283,21 @@ export class MenuService {
} }
async updateMenuItem(id: string, update: Partial<MenuItem>): Promise<MenuItem> { async updateMenuItem(id: string, update: Partial<MenuItem>): Promise<MenuItem> {
const existing = this.userOverrides.get(id) || { id }; const { menu: fullMenu } = await this.getFullMenuWithCache();
this.userOverrides.set(id, { ...existing, ...update }); const item = this.findMenuItem(fullMenu, id);
await this.saveUserOverrides(); if (!item) throw new Error('Menu item not found');
this.invalidateCache(); Object.assign(item, update);
const { menu } = await this.getFullMenuWithCache();
const updated = this.findMenuItem(menu, id);
if (!updated) { this.menuCache = null;
throw new HttpException('Updated item not found', HttpStatus.NOT_FOUND); return item;
} }
return updated; async saveOverrides(overrides: Partial<MenuItem>[]): Promise<void> {
await fs.writeFile(this.menuOverridesPath, JSON.stringify({ overrides }, null, 2), 'utf-8');
this.menuCache = null;
} }
invalidateCache(): void { invalidateCache(): void {

View File

View File

@ -1,5 +0,0 @@
// 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,39 +1,23 @@
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; import { Injectable, Logger, OnModuleInit } 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, OnModuleDestroy { export class MetricsGateway implements OnModuleInit {
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>;
// Real-time подписки (одна на метрику, много клиентов) private activeSockets = new Map<string, WebSocket>();
private realtimeSubscriptions = new Map<string, RealtimeSubscription>(); private metricSubscriptions = new Map<
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,
@ -41,429 +25,367 @@ export class MetricsGateway implements OnModuleInit, OnModuleDestroy {
) { } ) { }
onModuleInit() { onModuleInit() {
this.httpServer = createServer(); const httpServer = createServer();
this.wss = new Server({ this.wss = new Server({
server: this.httpServer, server: 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);
this.httpServer.listen(wsPort, () => { httpServer.listen(wsPort, () => {
this.logger.log( this.logger.log(
`WebSocket server running at ws://localhost:${wsPort}/metrics-ws` `WebSocket server running at ws://localhost:${wsPort}/api/metrics-ws`
); );
}); });
} }
onModuleDestroy() {
// Очистка всех ресурсов
this.clearAllSubscriptions();
this.wss?.close();
this.httpServer?.close();
}
private handleConnection(client: WebSocket, request: any) { private handleConnection(client: WebSocket, request: any) {
const clientId = this.getClientId(request?.url); let clientId =
this.activeClients.set(clientId, client); this.getQueryParams(request?.url).clientId ||
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 message = JSON.parse(raw.toString()); const msg = JSON.parse(raw.toString());
this.handleMessage(clientId, client, message); this.handleMessage(clientId, client, msg);
} catch (err) { } catch (err) {
this.sendError(client, 'Invalid JSON format'); this.sendError(client, 'Invalid JSON');
} }
}); });
client.on('close', () => this.handleClientDisconnect(clientId)); client.on('close', () => this.cleanupClient(clientId));
client.on('error', (err) => { client.on('error', (err) => {
this.logger.error(`Client ${clientId} error:`, err); this.logger.error(`Client ${clientId} error:`, err);
this.handleClientDisconnect(clientId); this.cleanupClient(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, requestId } = message; const { event, data } = 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.handleUnsubscribeAll(clientId, requestId); return this.unsubscribeAllForClient(clientId);
case 'get-historical': case 'get-metrics':
return this.handleGetHistorical(client, data, requestId); return this.handleGetMetrics(client, data);
case 'get-current': case 'subscribe-metric':
return this.handleGetCurrent(client, data, requestId); return this.handleSubscribeMetric(clientId, client, data);
case 'unsubscribe-metric':
return this.handleUnsubscribeMetric(clientId, data);
default: default:
return this.sendError(client, `Unknown event type: ${event}`, requestId); return this.sendError(client, `Unknown event type: ${event}`);
} }
} }
private async handleSubscribeRealtime( private cleanupClient(clientId: string) {
clientId: string, if (!this.activeSockets.has(clientId)) return;
client: WebSocket, this.logger.log(`Client disconnected: ${clientId}`);
payload: any, this.activeSockets.delete(clientId);
requestId?: string
) { setTimeout(() => {
const { metric, filters = {}, interval = 10000 } = payload || {}; if (!this.activeSockets.has(clientId)) {
}
}, 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);
} }
const subscriptionKey = this.getSubscriptionKey(metric, filters); if (isRangeQuery) {
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);
this.logger.log(`Client ${clientId} added to existing subscription: ${subscriptionKey}`); const initialData =
await this.prometheusService.fetchMetricsWithFilters(metric, filters);
// Отправляем последние данные клиенту
if (subscription.lastData) {
this.sendMessage(client, { this.sendMessage(client, {
event: 'realtime-data', event: 'metrics-data',
data: { data: { metric, data: initialData, requestId },
metric,
filters,
data: subscription.lastData,
type: 'initial'
},
requestId
}); });
let lastLocal = initialData;
const jitter = Math.floor(Math.random() * 5000);
setTimeout(() => {
const timer = setInterval(async () => {
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) {
const { metric, interval = 60000, filters = {} } = payload || {};
if (!metric) {
this.sendError(client, 'Metric name is required');
return; return;
} }
// Создаем новую подписку const key = this.getSubscriptionKey(metric, filters);
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, {
event: 'realtime-data',
data: {
metric,
filters,
data: initialData,
type: 'initial'
},
requestId
});
this.logger.debug(`Subscription created for ${subscriptionKey} with ${interval}ms interval`);
} catch (error) {
this.logger.error(`Subscribe error for ${subscriptionKey}:`, error);
this.sendError(client, error.message, requestId);
}
}
private async updateRealtimeData(subscriptionKey: string) {
const subscription = this.realtimeSubscriptions.get(subscriptionKey);
if (!subscription) return;
try { try {
const freshData = await this.prometheusService.fetchMetricsWithFilters( const initial = await this.prometheusService.fetchMetricsWithFilters(metric, filters);
subscription.metric,
subscription.filters
);
if (!this.isDataEqual(subscription.lastData, freshData)) { if (!Array.isArray(initial)) {
subscription.lastData = freshData; throw new Error(`Expected array for metric ${metric}, got ${typeof initial}`);
}
// Рассылаем обновление всем подписанным клиентам this.sendMessage(client, {
this.broadcastToClients(Array.from(subscription.clients), { event: 'metrics-data',
event: 'realtime-data',
data: { data: {
metric: subscription.metric, metric: key,
filters: subscription.filters, data: initial,
type: 'initial'
}
});
this.lastSentData.set(key, initial);
const stopUpdates = await this.sendPeriodicUpdates(
metric,
interval,
(freshData) => {
if (!Array.isArray(freshData)) {
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, data: freshData,
type: 'update' type: 'update'
} }
}); });
this.lastSentData.set(key, freshData);
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) {
return;
}
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 {
this.logger.debug(`Fetching historical data for: ${metric}, from ${new Date(start).toISOString()} to ${new Date(end).toISOString()}`);
const historicalData = await this.prometheusService.fetchMetricsRange(
metric,
Math.floor(start / 1000), // Convert to seconds
Math.floor(end / 1000), // Convert to seconds
step,
filters filters
); );
this.sendMessage(client, { this.metricSubscriptions.set(key, {
event: 'historical-data', stopUpdates,
data: { clients: new Set([clientId]),
metric,
filters,
data: historicalData,
start,
end,
step
},
requestId
}); });
this.logger.debug(`Historical data sent for: ${metric}, points: ${historicalData.length}`); const unsubscribe = () => {
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(`Historical data error for ${metric}:`, error); this.logger.error(`Subscription error for ${key}:`, error);
this.sendError(client, error.message, requestId); this.sendError(client, error.message);
if (!this.metricSubscriptions.has(key)) {
this.lastSentData.delete(key);
}
} }
} }
private async handleGetCurrent( private handleUnsubscribeMetric(
client: WebSocket, clientId: string,
payload: any, payload: { metric: string; filters?: Filters }
requestId?: string
) { ) {
const { metric, filters = {} } = payload || {}; const { metric, filters = {} } = payload || {};
if (!metric) return;
if (!metric) { const key = this.getSubscriptionKey(metric, filters);
return this.sendError(client, 'Metric name is required', requestId); const sub = this.metricSubscriptions.get(key);
if (!sub) return;
sub.clients.delete(clientId);
if (sub.clients.size === 0) {
sub.stopUpdates();
this.metricSubscriptions.delete(key);
this.lastSentData.delete(key);
}
} }
private getQueryParams(rawUrl?: string): Record<string, string> {
try { try {
const currentData = await this.prometheusService.fetchMetricsWithFilters(metric, filters); const url = new URL(rawUrl || '', 'http://localhost'); // безопасная база
return Object.fromEntries(url.searchParams.entries());
this.sendMessage(client, {
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}`);
// Удаляем клиента из всех подписок
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 {
if (!url) return {};
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 sortedFilters = Object.keys(filters) const keys = Object.keys(filters).sort();
.sort() const filterString = keys
.map(key => `${key}=${encodeURIComponent(filters[key])}`) .map((k) => `${k}=${encodeURIComponent(filters[k])}`)
.join('&'); .join('&');
return `${metric}${filterString ? `?${filterString}` : ''}`;
return sortedFilters ? `${metric}?${sortedFilters}` : metric;
} }
private isDataEqual(a: PrometheusMetric[], b: PrometheusMetric[]): boolean { private isDataEqual(a: any[], b: any[]) {
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) { if (!a || !b || a.length !== b.length) return false;
return false; return a.every((item, i) => {
} const x = b[i];
return a.every((itemA, index) => {
const itemB = b[index];
return ( return (
itemA.value === itemB.value && item?.value === x?.value &&
itemA.timestamp === itemB.timestamp && item?.status === x?.status &&
itemA.device === itemB.device && item?.timestamp === x?.timestamp
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) {
try {
client.send(JSON.stringify(message)); client.send(JSON.stringify(message));
} catch (error) {
this.logger.error('Error sending message to client:', error);
}
} }
} }
private broadcastToClients(clientIds: string[], message: any) { private broadcast(message: any) {
const messageStr = JSON.stringify(message); const raw = JSON.stringify(message);
this.wss.clients.forEach((c) => {
clientIds.forEach(clientId => { if (c.readyState === WebSocket.OPEN) c.send(raw);
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: 'error', event: 'metrics-error',
data: { error, requestId }, data: { error, requestId },
requestId
}); });
} }
} }

View File

@ -1,49 +0,0 @@
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

@ -1,27 +0,0 @@
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,21 +1,13 @@
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, ConfigModule], imports: [HttpModule],
providers: [ providers: [PrometheusService, MetricsGateway],
PrometheusCacheService,
PrometheusQueryService,
PrometheusService,
MetricsGateway
],
controllers: [MetricsController], controllers: [MetricsController],
exports: [PrometheusService] exports: [PrometheusService]
}) })
export class PrometheusModule { } export class PrometheusModule {}

View File

@ -2,54 +2,46 @@ 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 { MenuItem } from '../menu/menu.interface';
import { PrometheusMetric } from './prometheus-metric.interface'; import { PrometheusMetric } from './prometheus-metric.interface';
import { PrometheusCacheService } from './prometheus-cache.service'; import { MenuItem } from '../menu/menu.interface';
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);
} }
private async executeQuery(url: string, params: any): Promise<any> {
const response = await lastValueFrom(
this.httpService.get(url, { params })
);
return response.data;
}
async fetchMetricType(metric: string): Promise<string | null> { async fetchMetricType(metric: string): Promise<string | null> {
const cacheKey = this.queryService.generateMetadataCacheKey(metric, 'type'); const cacheKey = `metadata-type-${metric}`;
const cacheEntry = this.cacheService.getMetadataCache(cacheKey); const cacheEntry = this.metadataCache.get(cacheKey);
if (cacheEntry && this.cacheService.isCacheValid(cacheEntry)) { if (cacheEntry && Date.now() - cacheEntry.timestamp < 30000) {
return cacheEntry.type; return cacheEntry.type;
} }
try { try {
const data = await this.executeQuery(`${this.prometheusUrl}/metadata`, { const response = await lastValueFrom(
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].type : null; const result = metadata?.length ? metadata[0].type : null;
this.cacheService.setMetadataCache(cacheKey, result, cacheEntry?.description); this.metadataCache.set(cacheKey, {
type: result,
description: cacheEntry?.description,
timestamp: Date.now()
});
return result; return result;
} catch (error) { } catch (error) {
console.error(`Ошибка при получении типа метрики ${metric}:`, error); console.error(`Ошибка при получении типа метрики ${metric}:`, error);
@ -58,22 +50,28 @@ export class PrometheusService {
} }
async fetchMetricDescription(metric: string): Promise<string | undefined> { async fetchMetricDescription(metric: string): Promise<string | undefined> {
const cacheKey = this.queryService.generateMetadataCacheKey(metric, 'description'); const cacheKey = `metadata-description-${metric}`;
const cacheEntry = this.cacheService.getMetadataCache(cacheKey); const cacheEntry = this.metadataCache.get(cacheKey);
if (cacheEntry && this.cacheService.isCacheValid(cacheEntry)) { if (cacheEntry && Date.now() - cacheEntry.timestamp < 30000) {
return cacheEntry.description; return cacheEntry.description;
} }
try { try {
const data = await this.executeQuery(`${this.prometheusUrl}/metadata`, { const response = await lastValueFrom(
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.cacheService.setMetadataCache(cacheKey, cacheEntry?.type ?? null, result); this.metadataCache.set(cacheKey, {
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);
@ -81,46 +79,39 @@ export class PrometheusService {
} }
} }
private transformMetricData(
entry: any,
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
};
}
async fetchMetrics(metric: string): Promise<PrometheusMetric[]> { async fetchMetrics(metric: string): Promise<PrometheusMetric[]> {
const cacheKey = this.queryService.generateCacheKey(metric); const cacheKey = `${metric}:{}`;
const cacheEntry = this.cacheService.getMetricCache(cacheKey); const cacheEntry = this.metricCache.get(cacheKey);
if (cacheEntry && this.cacheService.isCacheValid(cacheEntry)) { if (cacheEntry && Date.now() - cacheEntry.timestamp < 5000) {
return cacheEntry.data; return cacheEntry.data;
} }
try { try {
const data = await this.executeQuery(`${this.prometheusUrl}/query`, { const response = await lastValueFrom(
query: metric this.httpService.get(`${this.prometheusUrl}/query`, {
}); 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)
); );
this.cacheService.setMetricCache(cacheKey, result); const metricType = await this.fetchMetricType(metric);
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);
@ -130,27 +121,39 @@ 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 = this.queryService.generateCacheKey(metric, filters); const cacheKey = `${metric}:${JSON.stringify(filters)}`;
const cacheEntry = this.cacheService.getMetricCache(cacheKey); const cacheEntry = this.metricCache.get(cacheKey);
if (cacheEntry && this.cacheService.isCacheValid(cacheEntry)) { if (cacheEntry && Date.now() - cacheEntry.timestamp < 5000) {
return cacheEntry.data; return cacheEntry.data;
} }
try { try {
const query = this.queryService.buildFilteredQuery(metric, filters); const query = this.buildFilteredQuery(metric, filters);
const data = await this.executeQuery(`${this.prometheusUrl}/query`, { query }); const response = await lastValueFrom(
this.httpService.get(`${this.prometheusUrl}/query`, {
const [type, description] = await Promise.all([ params: { query }
this.fetchMetricType(metric), })
this.fetchMetricDescription(metric)
]);
const result = data.data.result.map((entry: any) =>
this.transformMetricData(entry, metric, type, description)
); );
this.cacheService.setMetricCache(cacheKey, result); const metricType = await this.fetchMetricType(metric);
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);
@ -159,42 +162,56 @@ export class PrometheusService {
} }
} }
async fetchMetricsRange( private buildFilteredQuery(metric: string, filters: Record<string, string>): string {
metric: string, const filterParts = Object.entries(filters)
start: number, .filter(([_, value]) => value !== undefined && value !== null && value !== "")
end: number, .map(([key, value]) => {
step: number, return `${key}="${value}"`;
filters: Record<string, string> = {} });
): Promise<PrometheusMetric[]> {
const query = this.queryService.buildFilteredQuery(metric, { return filterParts.length > 0
? `${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 data = await this.executeQuery(`${this.prometheusUrl}/query_range`, { const response = await lastValueFrom(
this.httpService.get(`${this.prometheusUrl}/query_range`, {
params: {
query, query,
start, start,
end, end,
step: optimalStep.toString() step: optimalStep.toString()
}); },
})
);
const [type, description] = await Promise.all([ const metricType = await this.fetchMetricType(metric);
this.fetchMetricType(metric), const metricDescription = await this.fetchMetricDescription(metric);
this.fetchMetricDescription(metric)
]);
return data.data.result.flatMap((entry: any) => return response.data.data.result.flatMap((entry) =>
entry.values.map((value: any) => ({ entry.values.map((value): PrometheusMetric => ({
__name__: entry.metric.__name__ || metric, __name__: entry.metric.__name__ || metric,
device: entry.metric.device || '', device: entry.metric.device,
source_id: entry.metric.source_id || '', instance: entry.metric.instance,
value: parseFloat(value[1]), job: entry.metric.job,
source_id: entry.metric.source_id,
status: entry.metric.status || '0',
timestamp: value[0] * 1000, timestamp: value[0] * 1000,
type: type || 'gauge', value: parseFloat(value[1]),
description type: metricType || 'gauge',
description: metricDescription,
...entry.metric
})) }))
); );
} catch (error) { } catch (error) {
@ -221,30 +238,36 @@ export class PrometheusService {
type?: string; type?: string;
}> { }> {
try { try {
const data = await this.executeQuery(`${this.prometheusUrl}/metadata`, { const response = await lastValueFrom(
this.httpService.get(`${this.prometheusUrl}/metadata`, {
params: { metric } params: { metric }
}); })
);
const metadata = data?.data?.[metric]?.[0]; const data = response.data?.data?.[metric]?.[0];
return { return {
name: metric, name: metric,
help: metadata?.help, help: data?.help,
type: metadata?.type type: data?.type
}; };
} catch (error) { } catch (error) {
console.error(`Error fetching metadata for ${metric}:`, error); console.error(`Error fetching metadata for ${metric}:`, error);
return { name: metric }; return {
name: metric
};
} }
} }
async fetchMetricSeries(metric: string): Promise<Record<string, string>[]> { async fetchMetricSeries(metric: string): Promise<Record<string, string>[]> {
try { try {
const data = await this.executeQuery(`${this.prometheusUrl}/series`, { const response = await lastValueFrom(
'match[]': metric this.httpService.get(`${this.prometheusUrl}/series`, {
}); params: { 'match[]': metric }
})
);
return data.data || []; return response.data.data || [];
} catch (error) { } catch (error) {
console.error(`Error fetching series for ${metric}:`, error); console.error(`Error fetching series for ${metric}:`, error);
return []; return [];
@ -253,15 +276,17 @@ export class PrometheusService {
async fetchAllMetrics(): Promise<string[]> { async fetchAllMetrics(): Promise<string[]> {
try { try {
const data = await this.executeQuery(`${this.prometheusUrl}/label/__name__/values`, {}); const response = await lastValueFrom(
return data.data; this.httpService.get(`${this.prometheusUrl}/label/__name__/values`)
);
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<Array<{ metric: string; data: PrometheusMetric[] }>> { async fetchAllMetricsWithValues(): Promise<any[]> {
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') ||
@ -283,6 +308,7 @@ export class PrometheusService {
} }
clearCache(): void { clearCache(): void {
this.cacheService.clearCache(); this.metricCache.clear();
this.metadataCache.clear();
} }
} }