import { Injectable } from '@nestjs/common'; import { PrometheusService } from '../prometheus.service'; import { MenuItem } from './menu.interface'; import * as fs from 'fs/promises'; import * as path from 'path'; import { RangeService } from './range.service'; @Injectable() export class MenuService { constructor( private readonly prometheusService: PrometheusService, private readonly rangeService: RangeService ) { } private readonly menuOverridesPath = path.join(process.cwd(), 'data', 'menu.json'); async saveMenuToFile(): Promise { const menu = await this.getFullMenu(); await fs.mkdir(path.dirname(this.menuOverridesPath), { recursive: true }); await fs.writeFile(this.menuOverridesPath, JSON.stringify(menu, null, 2), 'utf-8'); } async getFullMenu(): Promise { const dynamicItemsPromise = this.generateDynamicItems(); const baseMenu = await this.injectDynamicItems(this.getStaticStructure(), dynamicItemsPromise); const overrides = await this.loadOverrides(); return this.applyOverrides(baseMenu, overrides); } private applyOverrides(menu: MenuItem, overrides: Partial[]): MenuItem { const overrideMap = new Map(overrides.map(o => [o.id, o])); const apply = (item: MenuItem): MenuItem => { const override = overrideMap.get(item.id); const updated = override ? { ...item, ...override } : item; if (updated.items) { updated.items = updated.items.map(apply); } return updated; }; return apply(menu); } private async loadOverrides(): Promise[]> { try { const content = await fs.readFile(this.menuOverridesPath, 'utf-8'); const parsed = JSON.parse(content); return parsed.overrides || []; } catch (e) { return []; } } 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 })); }) ) ).flat(); const metadataMap = new Map(); await Promise.all( metricNames.map(async metric => { try { const meta = await this.prometheusService.fetchMetricMetadata(metric); if (meta?.help) { metadataMap.set(metric, meta.help); } } catch (e) { console.warn(`No metadata for metric ${metric}`); } }) ); const isGarbageDevice = (device: string) => device.startsWith('/dev') || device.startsWith('/proc') || device.startsWith('/sys') || device.startsWith('/rootfs') || device.startsWith('/var') || device.startsWith('overlay') || device.startsWith('br') || device.startsWith('docker0') || device.startsWith('ens18') || device.startsWith('sda') || device.startsWith('sr0') || device.startsWith('tmpfs') || device.startsWith('veth') || device.startsWith('gvfsd') || device.startsWith('lo') || device.startsWith('/run'); const isGarbageInstance = (instance: string) => instance.includes('192.168.2.34:9049'); const filteredSeries = allSeries.filter(({ labels }) => { const device = labels.device; const instance = labels.instance; return (!device || !isGarbageDevice(device)) && (!instance || !isGarbageInstance(instance)); }); const devices = this.extractUniqueEntities(filteredSeries, 'device'); const deviceItems = await Promise.all( devices.map(device => this.createDeviceItem(device, allSeries, metadataMap)) ); return deviceItems; } private async createDeviceItem( device: string, seriesData: { metric: string; labels: Record }[], metadataMap: Map ): Promise { const moduleItems = await this.generateModuleItems(device, seriesData, metadataMap); return { id: `device_${device}`, title: `Graviton S2082I (${device})`, items: moduleItems, 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 normalizeIdPart(part: string): string { return part .replace(/\$/g, '_') .replace(/[^a-zA-Z0-9-_]/g, ''); } private async generateModuleItems( device: string, seriesData: { metric: string; labels: Record }[], metadataMap: Map ): Promise { const modules = new Map(); 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 }[], metadataMap: Map ): Promise { 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 injectDynamicItems( menu: MenuItem, dynamicItemsPromise: Promise ): Promise { const dynamicItems = await dynamicItemsPromise; if (menu.id === 'media_servers') { return { ...menu, items: dynamicItems }; } if (menu.items) { const updatedItems = await Promise.all( menu.items.map(item => this.injectDynamicItems(item, dynamicItemsPromise)) ); return { ...menu, items: updatedItems }; } return menu; } 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; } async saveOverrides(overrides: Partial[]): Promise { await fs.writeFile(this.menuOverridesPath, JSON.stringify({ overrides }, null, 2), 'utf-8'); } 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; } }