From 1a63f20bb0fe9e9661d872d7147a9038309ebc6a Mon Sep 17 00:00:00 2001 From: DmitriyA Date: Mon, 26 May 2025 07:48:41 -0400 Subject: [PATCH] automatic json generation for the sidebar menu --- src/MenuItem.interface.ts | 10 ++ src/Subscription.interface.ts | 0 src/app.module.ts | 15 +-- src/menu/menu.controller.ts | 21 +++++ src/menu/menu.interface.ts | 9 ++ src/menu/menu.module.ts | 11 +++ src/menu/menu.service.ts | 145 +++++++++++++++++++++++++++++ src/metrics.gateway.ts | 2 +- src/prometheus-metric.interface.ts | 16 ++-- src/prometheus.module.ts | 13 +++ src/prometheus.service.ts | 78 ++++++++++++---- 11 files changed, 279 insertions(+), 41 deletions(-) create mode 100644 src/MenuItem.interface.ts create mode 100644 src/Subscription.interface.ts create mode 100644 src/menu/menu.controller.ts create mode 100644 src/menu/menu.interface.ts create mode 100644 src/menu/menu.module.ts create mode 100644 src/menu/menu.service.ts create mode 100644 src/prometheus.module.ts diff --git a/src/MenuItem.interface.ts b/src/MenuItem.interface.ts new file mode 100644 index 0000000..102f903 --- /dev/null +++ b/src/MenuItem.interface.ts @@ -0,0 +1,10 @@ +export interface MenuItem { + id: string; + title: string; + items?: MenuItem[]; + metric?: string; + filters?: { + device: string; + source_id: string; + }; +} \ No newline at end of file diff --git a/src/Subscription.interface.ts b/src/Subscription.interface.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/app.module.ts b/src/app.module.ts index e8ecfad..c224821 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,11 +1,10 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { HttpModule } from '@nestjs/axios'; -import { PrometheusService } from './prometheus.service'; -import { MetricsController } from './metrics.controller'; import { ConfigModule } from '@nestjs/config'; import { AuthModule } from './auth/auth.module'; -import { MetricsGateway } from './metrics.gateway'; +import { MenuModule } from './menu/menu.module'; +import { PrometheusModule } from './prometheus.module'; @Module({ imports: [ @@ -26,12 +25,8 @@ import { MetricsGateway } from './metrics.gateway'; }), HttpModule, AuthModule, + PrometheusModule, + MenuModule, ], - controllers: [MetricsController], - providers: [ - PrometheusService, - MetricsGateway, - ], - exports: [MetricsGateway], }) -export class AppModule { } \ No newline at end of file +export class AppModule {} diff --git a/src/menu/menu.controller.ts b/src/menu/menu.controller.ts new file mode 100644 index 0000000..c08b10b --- /dev/null +++ b/src/menu/menu.controller.ts @@ -0,0 +1,21 @@ +import { Controller, Get, Param, Post, Body, Put } from '@nestjs/common'; +import { MenuService } from './menu.service'; +import { MenuItem } from './menu.interface'; + +@Controller('menu') +export class MenuController { + constructor(private readonly menuService: MenuService) {} + + @Get() + async getMenu(): Promise { + return this.menuService.getFullMenu(); + } + + @Put(':id') + async updateMenuItem( + @Param('id') id: string, + @Body() update: Partial + ): Promise { + return this.menuService.updateMenuItem(id, update); + } +} \ No newline at end of file diff --git a/src/menu/menu.interface.ts b/src/menu/menu.interface.ts new file mode 100644 index 0000000..654e678 --- /dev/null +++ b/src/menu/menu.interface.ts @@ -0,0 +1,9 @@ +export interface MenuItem { + title: string; + id: string; + items?: MenuItem[]; + metric?: string; + filters?: Record; + isDynamic?: boolean; + templateId?: string; + } \ No newline at end of file diff --git a/src/menu/menu.module.ts b/src/menu/menu.module.ts new file mode 100644 index 0000000..dec7701 --- /dev/null +++ b/src/menu/menu.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { MenuController } from './menu.controller'; +import { MenuService } from './menu.service'; +import { PrometheusModule } from '../prometheus.module'; // Импортируем PrometheusModule + +@Module({ + imports: [PrometheusModule], // Добавляем в imports + controllers: [MenuController], + providers: [MenuService] +}) +export class MenuModule {} \ No newline at end of file diff --git a/src/menu/menu.service.ts b/src/menu/menu.service.ts new file mode 100644 index 0000000..12c9db1 --- /dev/null +++ b/src/menu/menu.service.ts @@ -0,0 +1,145 @@ +import { Injectable, Inject } from '@nestjs/common'; +import { PrometheusService } from '../prometheus.service'; +import { MenuItem } from './menu.interface'; + +@Injectable() +export class MenuService { + constructor( + private readonly prometheusService: PrometheusService // Просто объявляем зависимость + ) {} + + async getFullMenu(): Promise { + // Реализация остается прежней + const dynamicItems = await this.generateDynamicItems(); + return this.injectDynamicItems(this.getStaticStructure(), dynamicItems); + } + + private getStaticStructure(): MenuItem { + return { + title: "ЗВКС", + id: "root", + items: [ + { + title: "ВКС", + id: "vks", + items: [ + { + title: "Медиа серверы", + id: "media_servers", + items: [] + } + ] + } + ] + }; + } + + private async generateDynamicItems(): Promise { + const metricNames = await this.prometheusService.fetchAllMetrics(); + + const allSeries = await Promise.all( + metricNames.map(async name => { + const series = await this.prometheusService.fetchMetricSeries(name); + return series.map(s => ({ + metric: name, + labels: s + })); + }) + ); + + const flatSeries = allSeries.flat(); + + const devices = this.extractUniqueEntities(flatSeries, 'device'); + + return devices.map(device => ({ + id: `device_${device}`, + title: `Graviton S2082I (${device})`, + items: this.generateModuleItems(device, flatSeries), + isDynamic: true + })); +} + + + private extractUniqueEntities(metrics: any[], field: string): string[] { + const entities = new Set(); + metrics.forEach(meta => { + if (meta.labels?.[field]) { + entities.add(meta.labels[field]); + } + }); + return Array.from(entities); + } + + private generateModuleItems(device: string, seriesData: { metric: string, labels: Record }[]): MenuItem[] { + const modules = new Set(); + + seriesData.forEach(({ labels }) => { + if (labels.device === device && labels.source_id) { + modules.add(labels.source_id); + } + }); + + return Array.from(modules).map(module => ({ + id: `module_${module.replace('module$', '')}`, + title: `OS Linux АО (${module})`, + items: this.generateMetricItems(device, module, seriesData), + isDynamic: true + })); + } + + + private generateMetricItems(device: string, module: string, seriesData: { metric: string, labels: Record }[]): MenuItem[] { + const filtered = seriesData.filter( + ({ labels }) => labels.device === device && labels.source_id === module + ); + + const uniqueMetrics = new Set(filtered.map(entry => entry.metric)); + + return Array.from(uniqueMetrics).map(metric => ({ + id: `metric_${device}_${module}_${metric}`, + title: metric, // или запрашивать описание отдельно + metric, + filters: { + device, + source_id: module + }, + isDynamic: true + })); + } + + + private injectDynamicItems(menu: MenuItem, dynamicItems: MenuItem[]): MenuItem { + if (menu.id === 'media_servers') { + return { ...menu, items: dynamicItems }; + } + + return { + ...menu, + items: menu.items?.map(item => this.injectDynamicItems(item, dynamicItems)) || [] + }; + } + + + async updateMenuItem(id: string, update: Partial): Promise { + const fullMenu = await this.getFullMenu(); + const item = this.findMenuItem(fullMenu, id); + + if (!item) throw new Error('Menu item not found'); + Object.assign(item, update); + + return item; + } + + private findMenuItem(menu: MenuItem, id: string): MenuItem | null { + if (menu.id === id) return menu; + + if (menu.items) { + for (const item of menu.items) { + const found = this.findMenuItem(item, id); + if (found) return found; + } + } + + return null; + } +} \ No newline at end of file diff --git a/src/metrics.gateway.ts b/src/metrics.gateway.ts index d2a2ae9..dc78ab7 100644 --- a/src/metrics.gateway.ts +++ b/src/metrics.gateway.ts @@ -27,7 +27,7 @@ export class MetricsGateway implements OnGatewayInit, OnGatewayConnection, OnGat clients: Set; }>(); - constructor(private readonly prometheusService: PrometheusService) {} + constructor(private readonly prometheusService: PrometheusService) { } afterInit(server: Server) { this.logger.log('WebSocket Gateway initialized'); diff --git a/src/prometheus-metric.interface.ts b/src/prometheus-metric.interface.ts index daa77c8..ac0dac7 100644 --- a/src/prometheus-metric.interface.ts +++ b/src/prometheus-metric.interface.ts @@ -1,13 +1,9 @@ export interface PrometheusMetric { __name__: string; - device?: string; - instance?: string; - job?: string; - source_id?: string; - status: string; - timestamp: number; - value: number; - type: string; - description?: string; - [key: string]: string | number | undefined; + device: string; + source_id: string; + value: number; + timestamp: number; + type?: string; + description?: string; } \ No newline at end of file diff --git a/src/prometheus.module.ts b/src/prometheus.module.ts new file mode 100644 index 0000000..994b741 --- /dev/null +++ b/src/prometheus.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { HttpModule } from '@nestjs/axios'; +import { PrometheusService } from './prometheus.service'; +import { MetricsController } from './metrics.controller'; +import { MetricsGateway } from './metrics.gateway'; + +@Module({ + imports: [HttpModule], + providers: [PrometheusService, MetricsGateway], + controllers: [MetricsController], + exports: [PrometheusService] + }) + export class PrometheusModule {} \ No newline at end of file diff --git a/src/prometheus.service.ts b/src/prometheus.service.ts index 734f5d8..dbbe835 100644 --- a/src/prometheus.service.ts +++ b/src/prometheus.service.ts @@ -3,6 +3,7 @@ import { HttpService } from '@nestjs/axios'; import { ConfigService } from '@nestjs/config'; import { lastValueFrom } from 'rxjs'; import { PrometheusMetric } from './prometheus-metric.interface'; +import { MenuItem } from './menu/menu.interface'; @Injectable() export class PrometheusService { @@ -16,7 +17,6 @@ export class PrometheusService { console.log('Prometheus API URL:', this.prometheusUrl); } - // Получаем тип метрики async fetchMetricType(metric: string): Promise { try { const response = await lastValueFrom( @@ -24,7 +24,6 @@ export class PrometheusService { params: { metric }, }) ); - const metadata = response.data.data[metric]; return metadata?.length ? metadata[0].type : null; } catch (error) { @@ -33,7 +32,6 @@ export class PrometheusService { } } - // Получаем описание метрики async fetchMetricDescription(metric: string): Promise { try { const response = await lastValueFrom( @@ -41,7 +39,6 @@ export class PrometheusService { params: { metric }, }) ); - const metadata = response.data.data[metric]; return metadata?.length ? metadata[0].help : undefined; } catch (error) { @@ -50,7 +47,6 @@ export class PrometheusService { } } - // Получаем данные метрики (текущие значения) async fetchMetrics(metric: string): Promise { try { const response = await lastValueFrom( @@ -68,12 +64,12 @@ export class PrometheusService { instance: entry.metric.instance, job: entry.metric.job, source_id: entry.metric.source_id, - status: entry.metric.status || '0', // По умолчанию '0' (аналог 'green' в старом формате) + status: entry.metric.status || '0', timestamp: entry.value[0] * 1000, value: parseFloat(entry.value[1]), - type: metricType || 'gauge', // По умолчанию 'gauge' + type: metricType || 'gauge', description: metricDescription, - ...entry.metric // Добавляем остальные поля метрики + ...entry.metric })); } catch (error) { console.error(`Error fetching metrics for ${metric}:`, error); @@ -116,27 +112,20 @@ export class PrometheusService { const filterParts = Object.entries(filters) .filter(([_, value]) => value !== undefined && value !== null && value !== "") .map(([key, value]) => { - // Для source_id добавляем module$ префикс, если его нет if (key === 'source_id' && !value.startsWith('module$')) { return `${key}="module$${value}"`; } return `${key}="${value}"`; }); - const query = filterParts.length > 0 + return filterParts.length > 0 ? `${metric}{${filterParts.join(',')}}` : metric; - - console.log('Generated PromQL query:', query); // Добавьте этот лог - return query; } - // Получаем данные метрики за интервал async fetchMetricsRange(metric: string, start: number, end: number, step: number, filters: Record = {}): Promise { - const query = this.buildFilteredQuery(metric, filters); // <-- ВНЕ try + const query = this.buildFilteredQuery(metric, filters); try { - console.log('Executing range query:', { query, start, end, step }); - const response = await lastValueFrom( this.httpService.get(`${this.prometheusUrl}/query_range`, { params: { @@ -176,8 +165,58 @@ export class PrometheusService { } } + async getMetricsForMenuItem(menuItem: MenuItem): Promise { + if (!menuItem.metric || !menuItem.filters) { + throw new Error('MenuItem is not a metric item'); + } + + return this.fetchMetricsWithFilters(menuItem.metric, menuItem.filters); + } + + // ✅ Новый метод: получает базовое описание метрики (help, type) + async fetchMetricMetadata(metric: string): Promise<{ + name: string; + help?: string; + type?: string; + }> { + try { + const response = await lastValueFrom( + this.httpService.get(`${this.prometheusUrl}/metadata`, { + params: { metric } + }) + ); + + const data = response.data?.data?.[metric]?.[0]; + + return { + name: metric, + help: data?.help, + type: data?.type + }; + } catch (error) { + console.error(`Error fetching metadata for ${metric}:`, error); + return { + name: metric + }; + } + } + + // ✅ Новый метод: получает ВСЕ серии метрики (все комбинации label-ов) + async fetchMetricSeries(metric: string): Promise[]> { + try { + const response = await lastValueFrom( + this.httpService.get(`${this.prometheusUrl}/series`, { + params: { 'match[]': metric } + }) + ); + + return response.data.data || []; + } catch (error) { + console.error(`Error fetching series for ${metric}:`, error); + return []; + } + } - // Получаем список всех метрик async fetchAllMetrics(): Promise { const response = await lastValueFrom( this.httpService.get(`${this.prometheusUrl}/label/__name__/values`) @@ -185,7 +224,6 @@ export class PrometheusService { return response.data.data; } - // Получаем все метрики с их значениями async fetchAllMetricsWithValues(): Promise { const metricNames = await this.fetchAllMetrics(); const promises = metricNames.map(async (metric) => { @@ -194,4 +232,4 @@ export class PrometheusService { }); return Promise.all(promises); } -} \ No newline at end of file +}