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 { MenuItem } from './menu.interface';
@ -44,72 +44,24 @@ export class MenuController {
return { hasUpdates };
}
// @Post('save')
// async saveMenu() {
// await this.menuService.saveMenuToFile();
// return { status: 'saved' };
// }
@Post('save')
async saveMenu() {
await this.menuService.saveMenuToFile();
return { status: 'saved' };
}
// @Post('overrides')
// async saveOverrides(@Body() data: { overrides: Partial<MenuItem>[] }) {
// await this.menuService.saveOverrides(data.overrides);
// return { status: 'success' };
// }
@Post('overrides')
async saveOverrides(@Body() data: { overrides: Partial<MenuItem>[] }) {
await this.menuService.saveOverrides(data.overrides);
return { status: 'success' };
}
@Put(':id')
async updateMenuItem(
@Param('id') id: string,
@Body() update: Partial<MenuItem>
) {
try {
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,5 +11,4 @@ export interface MenuItem {
max: number;
status: number;
}>;
hidden?: boolean;
}

View File

@ -10,28 +10,29 @@ export class MenuService {
private menuCache: MenuItem | null = null;
private lastModified: Date | null = null;
private cacheInitialized = false;
private userOverrides: Map<string, Partial<MenuItem>> = new Map();
constructor(
private readonly prometheusService: PrometheusService,
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 }> {
if (this.menuCache && this.lastModified && (!ifModifiedSince || new Date(ifModifiedSince) >= this.lastModified)) {
return { menu: this.menuCache, fresh: false };
}
await this.loadUserOverrides();
const dynamicItemsPromise = this.generateDynamicItems();
const baseMenu = await this.injectDynamicItems(this.getStaticStructure(), dynamicItemsPromise);
const freshMenu = this.applyUserOverrides(baseMenu);
const overrides = await this.loadOverrides();
const freshMenu = this.applyOverrides(baseMenu, overrides);
this.menuCache = freshMenu;
this.lastModified = new Date();
@ -47,75 +48,33 @@ export class MenuService {
return !this.lastModified || new Date(ifModifiedSince) < this.lastModified;
}
async hideMenuItem(id: string): Promise<void> {
this.userOverrides.set(id, { id, hidden: true });
await this.saveUserOverrides();
this.invalidateCache();
}
private applyOverrides(menu: MenuItem, overrides: Partial<MenuItem>[]): MenuItem {
const overrideMap = new Map(overrides.map(o => [o.id, o]));
private applyUserOverrides(menu: MenuItem): MenuItem {
const apply = (item: MenuItem): MenuItem | null => {
const override = this.userOverrides.get(item.id);
if (override?.hidden) {
return null;
}
const updated: MenuItem = {
...item,
...override,
hidden: undefined
};
const apply = (item: MenuItem): MenuItem => {
const override = overrideMap.get(item.id);
const updated = override ? { ...item, ...override } : item;
if (updated.items) {
const processedItems = updated.items
.map(apply)
.filter((item): item is MenuItem => item !== null);
updated.items = processedItems.length > 0 ? processedItems : undefined;
updated.items = updated.items.map(apply);
}
return updated;
};
const result = apply(menu);
return result || { title: menu.title, id: menu.id, items: [] };
return apply(menu);
}
private async loadUserOverrides(): Promise<void> {
private async loadOverrides(): Promise<Partial<MenuItem>[]> {
try {
const content = await fs.readFile(this.userOverridesPath, 'utf-8');
const content = await fs.readFile(this.menuOverridesPath, 'utf-8');
const parsed = JSON.parse(content);
this.userOverrides = new Map(
(parsed.overrides || []).map(o => [o.id, o])
);
return parsed.overrides || [];
} catch (e) {
this.userOverrides = new Map();
await this.saveUserOverrides(); // Создаем файл с пустыми данными
return [];
}
}
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 {
return {
title: "ЗВКС",
@ -207,12 +166,9 @@ export class MenuService {
metadataMap: Map<string, string>
): Promise<MenuItem> {
const moduleItems = await this.generateModuleItems(device, seriesData, metadataMap);
const deviceName = metadataMap.get(device) ?? device;
return {
id: `device_${device}`,
title: deviceName,
title: `Graviton S2082I (${device})`,
items: moduleItems,
isDynamic: true
};
@ -231,175 +187,41 @@ export class MenuService {
private normalizeIdPart(part: string): string {
return part
.replace(/\$/g, '_')
.replace(/,/g, '_')
.replace(/\s+/g, '_')
.replace(/[^a-zA-Z0-9-_]/g, '')
.toLowerCase();
.replace(/[^a-zA-Z0-9-_]/g, '');
}
// 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(
device: string,
seriesData: { metric: string; labels: Record<string, string> }[],
metadataMap: Map<string, string>
): Promise<MenuItem[]> {
const modules = new Map<string, string>();
const specialFolders = new Map<string, Map<string, string>>();
seriesData.forEach(({ labels }) => {
if (labels.device === device && labels.source_id) {
const sourceId = this.normalizeSourceId(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 {
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 moduleItems = Array.from(modules.entries()).map(
const modulePromises = Array.from(modules.entries()).map(
async ([sourceId, displayName]) => ({
id: `module_${device}_${this.normalizeIdPart(sourceId)}`,
id: `module_${device}_${sourceId}`,
title: displayName,
items: await this.generateMetricItems(device, sourceId, seriesData, metadataMap),
isDynamic: true
})
);
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(', ');
return Promise.all(modulePromises);
}
private async generateMetricItems(
@ -409,33 +231,13 @@ export class MenuService {
metadataMap: Map<string, string>
): Promise<MenuItem[]> {
const ranges = await this.rangeService.getRanges();
const normModule = this.normalizeSourceId(module);
const isPlainModule = !normModule.includes(',');
let filtered = seriesData.filter(({ labels }) =>
labels.device === device &&
this.normalizeSourceId(labels.source_id || '') === normModule
const filtered = seriesData.filter(
({ labels }) => labels.device === device && labels.source_id === module
);
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 safeDevice = this.normalizeIdPart(device);
const safeModule = this.normalizeIdPart(normModule);
const safeModule = this.normalizeIdPart(module);
return Array.from(uniqueMetrics).map(metric => {
const description = metadataMap.get(metric) || metric;
@ -448,13 +250,13 @@ export class MenuService {
metric,
filters: {
device,
source_id: normModule
source_id: module
},
ranges: metricRanges,
isDynamic: true,
meta: {
originalDevice: device,
originalModule: normModule
originalModule: module
}
};
});
@ -481,20 +283,21 @@ export class MenuService {
}
async updateMenuItem(id: string, update: Partial<MenuItem>): Promise<MenuItem> {
const existing = this.userOverrides.get(id) || { id };
this.userOverrides.set(id, { ...existing, ...update });
const { menu: fullMenu } = await this.getFullMenuWithCache();
const item = this.findMenuItem(fullMenu, id);
await this.saveUserOverrides();
this.invalidateCache();
if (!item) throw new Error('Menu item not found');
Object.assign(item, update);
const { menu } = await this.getFullMenuWithCache();
const updated = this.findMenuItem(menu, id);
if (!updated) {
throw new HttpException('Updated item not found', HttpStatus.NOT_FOUND);
this.menuCache = null;
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 {

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 { createServer } from 'http';
import { PrometheusService } from './prometheus.service';
import { ConfigService } from '@nestjs/config';
import { PrometheusMetric } from './prometheus-metric.interface';
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()
export class MetricsGateway implements OnModuleInit, OnModuleDestroy {
export class MetricsGateway implements OnModuleInit {
private readonly logger = new Logger(MetricsGateway.name);
private wss: Server;
private httpServer: ReturnType<typeof createServer>;
// Real-time подписки (одна на метрику, много клиентов)
private realtimeSubscriptions = new Map<string, RealtimeSubscription>();
private activeSockets = new Map<string, WebSocket>();
private metricSubscriptions = new Map<
string,
{ stopUpdates: () => void; clients: Set<string> }
>();
// Активные клиенты
private activeClients = new Map<string, WebSocket>();
// Исторические запросы (для отслеживания)
private historicalRequests = new Map<string, HistoricalRequest>();
private lastSentData = new Map<string, any>();
constructor(
private readonly prometheusService: PrometheusService,
@ -41,429 +25,367 @@ export class MetricsGateway implements OnModuleInit, OnModuleDestroy {
) { }
onModuleInit() {
this.httpServer = createServer();
const httpServer = createServer();
this.wss = new Server({
server: this.httpServer,
server: httpServer,
path: '/metrics-ws',
});
this.wss.on('connection', (client, request) =>
this.handleConnection(client, request)
);
this.wss.on('error', (err) =>
this.logger.error('WebSocket server error:', err)
);
const wsPort = Number(this.configService.get('WS_PORT') || 3001);
this.httpServer.listen(wsPort, () => {
httpServer.listen(wsPort, () => {
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) {
const clientId = this.getClientId(request?.url);
this.activeClients.set(clientId, client);
let clientId =
this.getQueryParams(request?.url).clientId ||
Math.random().toString(36).slice(2);
this.activeSockets.set(clientId, client);
this.logger.log(`Client connected: ${clientId}`);
this.logger.debug(`Active clients: ${this.activeClients.size}, Subscriptions: ${this.realtimeSubscriptions.size}`);
client.on('message', (raw) => {
try {
const message = JSON.parse(raw.toString());
this.handleMessage(clientId, client, message);
const msg = JSON.parse(raw.toString());
this.handleMessage(clientId, client, msg);
} 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) => {
this.logger.error(`Client ${clientId} error:`, err);
this.handleClientDisconnect(clientId);
});
// Отправляем приветственное сообщение
this.sendMessage(client, {
event: 'connected',
data: { clientId, timestamp: Date.now() }
this.cleanupClient(clientId);
});
}
private handleMessage(clientId: string, client: WebSocket, message: any) {
const { event, data, requestId } = message;
if (!event) {
return this.sendError(client, 'Event type is required', requestId);
}
this.logger.debug(`Received event: ${event} from client: ${clientId}`);
const { event, data } = message || {};
if (!event) return this.sendError(client, 'Event type is required');
switch (event) {
case 'subscribe-realtime':
return this.handleSubscribeRealtime(clientId, client, data, requestId);
case 'unsubscribe-realtime':
return this.handleUnsubscribeRealtime(clientId, data, requestId);
case 'unsubscribe-all':
return this.handleUnsubscribeAll(clientId, requestId);
return this.unsubscribeAllForClient(clientId);
case 'get-historical':
return this.handleGetHistorical(client, data, requestId);
case 'get-metrics':
return this.handleGetMetrics(client, data);
case 'get-current':
return this.handleGetCurrent(client, data, requestId);
case 'subscribe-metric':
return this.handleSubscribeMetric(clientId, client, data);
case 'unsubscribe-metric':
return this.handleUnsubscribeMetric(clientId, data);
default:
return this.sendError(client, `Unknown event type: ${event}`, requestId);
return this.sendError(client, `Unknown event type: ${event}`);
}
}
private async handleSubscribeRealtime(
clientId: string,
client: WebSocket,
payload: any,
requestId?: string
) {
const { metric, filters = {}, interval = 10000 } = payload || {};
private cleanupClient(clientId: string) {
if (!this.activeSockets.has(clientId)) return;
this.logger.log(`Client disconnected: ${clientId}`);
this.activeSockets.delete(clientId);
setTimeout(() => {
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) {
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 {
// Если подписка уже существует, просто добавляем клиента
if (this.realtimeSubscriptions.has(subscriptionKey)) {
const subscription = this.realtimeSubscriptions.get(subscriptionKey)!;
subscription.clients.add(clientId);
const subscriptionKey = this.getSubscriptionKey(metric, filters);
this.logger.log(`Client ${clientId} added to existing subscription: ${subscriptionKey}`);
const initialData =
await this.prometheusService.fetchMetricsWithFilters(metric, filters);
// Отправляем последние данные клиенту
if (subscription.lastData) {
this.sendMessage(client, {
event: 'realtime-data',
data: {
metric,
filters,
data: subscription.lastData,
type: 'initial'
},
requestId
event: 'metrics-data',
data: { metric, data: initialData, 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;
}
// Создаем новую подписку
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;
const key = this.getSubscriptionKey(metric, filters);
try {
const freshData = await this.prometheusService.fetchMetricsWithFilters(
subscription.metric,
subscription.filters
);
const initial = await this.prometheusService.fetchMetricsWithFilters(metric, filters);
if (!this.isDataEqual(subscription.lastData, freshData)) {
subscription.lastData = freshData;
if (!Array.isArray(initial)) {
throw new Error(`Expected array for metric ${metric}, got ${typeof initial}`);
}
// Рассылаем обновление всем подписанным клиентам
this.broadcastToClients(Array.from(subscription.clients), {
event: 'realtime-data',
this.sendMessage(client, {
event: 'metrics-data',
data: {
metric: subscription.metric,
filters: subscription.filters,
metric: key,
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,
type: 'update'
}
});
this.logger.debug(`Data updated for subscription: ${subscriptionKey}`);
this.lastSentData.set(key, freshData);
}
} 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
);
this.sendMessage(client, {
event: 'historical-data',
data: {
metric,
filters,
data: historicalData,
start,
end,
step
},
requestId
this.metricSubscriptions.set(key, {
stopUpdates,
clients: new Set([clientId]),
});
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) {
this.logger.error(`Historical data error for ${metric}:`, error);
this.sendError(client, error.message, requestId);
this.logger.error(`Subscription error for ${key}:`, error);
this.sendError(client, error.message);
if (!this.metricSubscriptions.has(key)) {
this.lastSentData.delete(key);
}
}
}
private async handleGetCurrent(
client: WebSocket,
payload: any,
requestId?: string
private handleUnsubscribeMetric(
clientId: string,
payload: { metric: string; filters?: Filters }
) {
const { metric, filters = {} } = payload || {};
if (!metric) return;
if (!metric) {
return this.sendError(client, 'Metric name is required', requestId);
const key = this.getSubscriptionKey(metric, filters);
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 {
const currentData = await this.prometheusService.fetchMetricsWithFilters(metric, filters);
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());
const url = new URL(rawUrl || '', 'http://localhost'); // безопасная база
return Object.fromEntries(url.searchParams.entries());
} catch {
return {};
}
}
private getSubscriptionKey(metric: string, filters: Filters): string {
const sortedFilters = Object.keys(filters)
.sort()
.map(key => `${key}=${encodeURIComponent(filters[key])}`)
const keys = Object.keys(filters).sort();
const filterString = keys
.map((k) => `${k}=${encodeURIComponent(filters[k])}`)
.join('&');
return sortedFilters ? `${metric}?${sortedFilters}` : metric;
return `${metric}${filterString ? `?${filterString}` : ''}`;
}
private isDataEqual(a: PrometheusMetric[], b: PrometheusMetric[]): boolean {
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) {
return false;
}
return a.every((itemA, index) => {
const itemB = b[index];
private isDataEqual(a: any[], b: any[]) {
if (!a || !b || a.length !== b.length) return false;
return a.every((item, i) => {
const x = b[i];
return (
itemA.value === itemB.value &&
itemA.timestamp === itemB.timestamp &&
itemA.device === itemB.device &&
itemA.source_id === itemB.source_id
item?.value === x?.value &&
item?.status === x?.status &&
item?.timestamp === x?.timestamp
);
});
}
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) {
if (client.readyState === WebSocket.OPEN) {
try {
client.send(JSON.stringify(message));
} catch (error) {
this.logger.error('Error sending message to client:', error);
}
}
}
private broadcastToClients(clientIds: string[], message: any) {
const messageStr = JSON.stringify(message);
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 broadcast(message: any) {
const raw = JSON.stringify(message);
this.wss.clients.forEach((c) => {
if (c.readyState === WebSocket.OPEN) c.send(raw);
});
}
private sendError(client: WebSocket, error: string, requestId?: string) {
this.sendMessage(client, {
event: 'error',
event: 'metrics-error',
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,20 +1,12 @@
import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { ConfigModule } from '@nestjs/config';
import { PrometheusService } from './prometheus.service';
import { MetricsController } from './metrics.controller';
import { MetricsGateway } from './metrics.gateway';
import { PrometheusCacheService } from './prometheus-cache.service';
import { PrometheusQueryService } from './prometheus-query.service';
@Module({
imports: [HttpModule, ConfigModule],
providers: [
PrometheusCacheService,
PrometheusQueryService,
PrometheusService,
MetricsGateway
],
imports: [HttpModule],
providers: [PrometheusService, MetricsGateway],
controllers: [MetricsController],
exports: [PrometheusService]
})

View File

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