290 lines
8.4 KiB
TypeScript
290 lines
8.4 KiB
TypeScript
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<void> {
|
||
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<MenuItem> {
|
||
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>[]): 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<Partial<MenuItem>[]> {
|
||
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<MenuItem[]> {
|
||
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<string, string>();
|
||
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<string, string> }[],
|
||
metadataMap: Map<string, string>
|
||
): Promise<MenuItem> {
|
||
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<string>();
|
||
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<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 injectDynamicItems(
|
||
menu: MenuItem,
|
||
dynamicItemsPromise: Promise<MenuItem[]>
|
||
): Promise<MenuItem> {
|
||
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<MenuItem>): Promise<MenuItem> {
|
||
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<MenuItem>[]): Promise<void> {
|
||
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;
|
||
}
|
||
} |