From aaa2482f048581832354f7c64819ceb56937cb5b Mon Sep 17 00:00:00 2001 From: DmitriyA Date: Thu, 28 Aug 2025 09:15:42 -0400 Subject: [PATCH] added menu editor --- src/MenuItem.interface.ts | 10 -- src/menu/menu.controller.ts | 76 ++++++++-- src/menu/menu.interface.ts | 1 + src/menu/{menu-overrides.json => menu.json} | 0 src/menu/menu.service.ts | 159 ++++++++++++++------ 5 files changed, 175 insertions(+), 71 deletions(-) delete mode 100644 src/MenuItem.interface.ts rename src/menu/{menu-overrides.json => menu.json} (100%) diff --git a/src/MenuItem.interface.ts b/src/MenuItem.interface.ts deleted file mode 100644 index 102f903..0000000 --- a/src/MenuItem.interface.ts +++ /dev/null @@ -1,10 +0,0 @@ -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/menu/menu.controller.ts b/src/menu/menu.controller.ts index 197e747..d7b0ea4 100644 --- a/src/menu/menu.controller.ts +++ b/src/menu/menu.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Post, Put, Body, Param, Headers, HttpException, HttpStatus } from '@nestjs/common'; +import { Controller, Get, Post, Put, Body, Param, Headers, HttpException, HttpStatus, Delete } from '@nestjs/common'; import { MenuService } from './menu.service'; import { MenuItem } from './menu.interface'; @@ -44,24 +44,72 @@ 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[] }) { - await this.menuService.saveOverrides(data.overrides); - return { status: 'success' }; - } + // @Post('overrides') + // async saveOverrides(@Body() data: { overrides: Partial[] }) { + // await this.menuService.saveOverrides(data.overrides); + // return { status: 'success' }; + // } @Put(':id') async updateMenuItem( @Param('id') id: string, @Body() update: Partial ) { - const updatedItem = await this.menuService.updateMenuItem(id, update); - return updatedItem; + 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 + ); + } } -} \ No newline at end of file + + @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 + ); + } + } +} diff --git a/src/menu/menu.interface.ts b/src/menu/menu.interface.ts index 6608d56..f2b447c 100644 --- a/src/menu/menu.interface.ts +++ b/src/menu/menu.interface.ts @@ -11,4 +11,5 @@ export interface MenuItem { max: number; status: number; }>; + hidden?: boolean; } \ No newline at end of file diff --git a/src/menu/menu-overrides.json b/src/menu/menu.json similarity index 100% rename from src/menu/menu-overrides.json rename to src/menu/menu.json diff --git a/src/menu/menu.service.ts b/src/menu/menu.service.ts index b9ba54c..d07ec6c 100644 --- a/src/menu/menu.service.ts +++ b/src/menu/menu.service.ts @@ -10,29 +10,28 @@ export class MenuService { private menuCache: MenuItem | null = null; private lastModified: Date | null = null; private cacheInitialized = false; + private userOverrides: Map> = new Map(); 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.getFullMenuWithCache(); - await fs.mkdir(path.dirname(this.menuOverridesPath), { recursive: true }); - await fs.writeFile(this.menuOverridesPath, JSON.stringify(menu, null, 2), 'utf-8'); - } + private readonly userOverridesPath = path.join(process.cwd(), 'data', 'user_menu_overrides.json'); + 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 overrides = await this.loadOverrides(); - const freshMenu = this.applyOverrides(baseMenu, overrides); + + const freshMenu = this.applyUserOverrides(baseMenu); this.menuCache = freshMenu; this.lastModified = new Date(); @@ -48,33 +47,75 @@ export class MenuService { return !this.lastModified || new Date(ifModifiedSince) < this.lastModified; } - private applyOverrides(menu: MenuItem, overrides: Partial[]): MenuItem { - const overrideMap = new Map(overrides.map(o => [o.id, o])); + async hideMenuItem(id: string): Promise { + this.userOverrides.set(id, { id, hidden: true }); + await this.saveUserOverrides(); + this.invalidateCache(); + } - const apply = (item: MenuItem): MenuItem => { - const override = overrideMap.get(item.id); - const updated = override ? { ...item, ...override } : item; + 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 + }; if (updated.items) { - updated.items = updated.items.map(apply); + const processedItems = updated.items + .map(apply) + .filter((item): item is MenuItem => item !== null); + + updated.items = processedItems.length > 0 ? processedItems : undefined; } return updated; }; - return apply(menu); + const result = apply(menu); + return result || { title: menu.title, id: menu.id, items: [] }; } - private async loadOverrides(): Promise[]> { + private async loadUserOverrides(): Promise { try { - const content = await fs.readFile(this.menuOverridesPath, 'utf-8'); + const content = await fs.readFile(this.userOverridesPath, 'utf-8'); const parsed = JSON.parse(content); - return parsed.overrides || []; + + this.userOverrides = new Map( + (parsed.overrides || []).map(o => [o.id, o]) + ); } catch (e) { - return []; + this.userOverrides = new Map(); + await this.saveUserOverrides(); // Создаем файл с пустыми данными } } + + private async saveUserOverrides(): Promise { + 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: "ЗВКС", @@ -166,9 +207,12 @@ export class MenuService { metadataMap: Map ): Promise { const moduleItems = await this.generateModuleItems(device, seriesData, metadataMap); + + const deviceName = metadataMap.get(device) ?? device; + return { id: `device_${device}`, - title: `Graviton S2082I (${device})`, + title: deviceName, items: moduleItems, isDynamic: true }; @@ -187,12 +231,13 @@ export class MenuService { private normalizeIdPart(part: string): string { return part .replace(/\$/g, '_') - .replace(/,/g, '_') // Заменяем запятые - .replace(/\s+/g, '_') // Заменяем пробелы + .replace(/,/g, '_') + .replace(/\s+/g, '_') .replace(/[^a-zA-Z0-9-_]/g, '') .toLowerCase(); } + // private async generateModuleItems( // device: string, // seriesData: { metric: string; labels: Record }[], @@ -277,9 +322,8 @@ export class MenuService { seriesData.forEach(({ labels }) => { if (labels.device === device && labels.source_id) { - const sourceId = labels.source_id; + const sourceId = this.normalizeSourceId(labels.source_id); - // Проверяем наличие специальных тегов (complex, integration) if (sourceId.includes(', complex') || sourceId.includes(', integration')) { const [modulePart, folderType] = sourceId.split(', ').map(s => s.trim()); let displayName = modulePart; @@ -292,19 +336,15 @@ export class MenuService { 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; if (sourceId.startsWith('module$')) { displayName = `Module ${sourceId.split('$')[1]}`; @@ -325,7 +365,6 @@ export class MenuService { }) ); - // Создаем специальные папки const specialFolderItems = Array.from(specialFolders.entries()).map( async ([folderType, folderModules]) => { const folderItems = await Promise.all( @@ -333,7 +372,12 @@ export class MenuService { async ([sourceId, displayName]) => ({ id: `module_${device}_${this.normalizeIdPart(sourceId)}_${this.normalizeIdPart(folderType)}`, title: displayName, - items: await this.generateMetricItems(device, `${sourceId}, ${folderType}`, seriesData, metadataMap), + items: await this.generateMetricItems( + device, + `${sourceId}, ${folderType}`, + seriesData, + metadataMap + ), isDynamic: true }) ) @@ -354,6 +398,10 @@ export class MenuService { ]; } + private normalizeSourceId(raw: string): string { + return raw.split(',').map(s => s.trim()).filter(Boolean).join(', '); + } + private async generateMetricItems( device: string, module: string, @@ -362,14 +410,32 @@ export class MenuService { ): Promise { const ranges = await this.rangeService.getRanges(); - // Фильтруем по device и точному совпадению source_id - const filtered = seriesData.filter( - ({ labels }) => labels.device === device && labels.source_id === module + const normModule = this.normalizeSourceId(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 safeDevice = this.normalizeIdPart(device); - const safeModule = this.normalizeIdPart(module); + const safeModule = this.normalizeIdPart(normModule); return Array.from(uniqueMetrics).map(metric => { const description = metadataMap.get(metric) || metric; @@ -382,13 +448,13 @@ export class MenuService { metric, filters: { device, - source_id: module + source_id: normModule }, ranges: metricRanges, isDynamic: true, meta: { originalDevice: device, - originalModule: module + originalModule: normModule } }; }); @@ -415,21 +481,20 @@ export class MenuService { } async updateMenuItem(id: string, update: Partial): Promise { - const { menu: fullMenu } = await this.getFullMenuWithCache(); - const item = this.findMenuItem(fullMenu, id); + const existing = this.userOverrides.get(id) || { id }; + this.userOverrides.set(id, { ...existing, ...update }); - if (!item) throw new Error('Menu item not found'); - Object.assign(item, update); + await this.saveUserOverrides(); + this.invalidateCache(); + const { menu } = await this.getFullMenuWithCache(); + const updated = this.findMenuItem(menu, id); - this.menuCache = null; - return item; - } + if (!updated) { + throw new HttpException('Updated item not found', HttpStatus.NOT_FOUND); + } - async saveOverrides(overrides: Partial[]): Promise { - await fs.writeFile(this.menuOverridesPath, JSON.stringify({ overrides }, null, 2), 'utf-8'); - - this.menuCache = null; + return updated; } invalidateCache(): void {