added menu editor

pull/38/head
DmitriyA 2025-08-28 09:15:42 -04:00
parent d8a8d6b8e5
commit aaa2482f04
5 changed files with 175 additions and 71 deletions

View File

@ -1,10 +0,0 @@
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 } from '@nestjs/common'; import { Controller, Get, Post, Put, Body, Param, Headers, HttpException, HttpStatus, Delete } from '@nestjs/common';
import { MenuService } from './menu.service'; import { MenuService } from './menu.service';
import { MenuItem } from './menu.interface'; import { MenuItem } from './menu.interface';
@ -44,24 +44,72 @@ export class MenuController {
return { hasUpdates }; return { hasUpdates };
} }
@Post('save') // @Post('save')
async saveMenu() { // async saveMenu() {
await this.menuService.saveMenuToFile(); // await this.menuService.saveMenuToFile();
return { status: 'saved' }; // return { status: 'saved' };
} // }
@Post('overrides') // @Post('overrides')
async saveOverrides(@Body() data: { overrides: Partial<MenuItem>[] }) { // async saveOverrides(@Body() data: { overrides: Partial<MenuItem>[] }) {
await this.menuService.saveOverrides(data.overrides); // await this.menuService.saveOverrides(data.overrides);
return { status: 'success' }; // return { status: 'success' };
} // }
@Put(':id') @Put(':id')
async updateMenuItem( async updateMenuItem(
@Param('id') id: string, @Param('id') id: string,
@Body() update: Partial<MenuItem> @Body() update: Partial<MenuItem>
) { ) {
try {
const updatedItem = await this.menuService.updateMenuItem(id, update); const updatedItem = await this.menuService.updateMenuItem(id, update);
return updatedItem; 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,4 +11,5 @@ export interface MenuItem {
max: number; max: number;
status: number; status: number;
}>; }>;
hidden?: boolean;
} }

View File

@ -10,29 +10,28 @@ export class MenuService {
private menuCache: MenuItem | null = null; private menuCache: MenuItem | null = null;
private lastModified: Date | null = null; private lastModified: Date | null = null;
private cacheInitialized = false; private cacheInitialized = false;
private userOverrides: Map<string, Partial<MenuItem>> = new Map();
constructor( constructor(
private readonly prometheusService: PrometheusService, private readonly prometheusService: PrometheusService,
private readonly rangeService: RangeService private readonly rangeService: RangeService
) { } ) { }
private readonly menuOverridesPath = path.join(process.cwd(), 'data', 'menu.json');
async saveMenuToFile(): Promise<void> { private readonly userOverridesPath = path.join(process.cwd(), 'data', 'user_menu_overrides.json');
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 }> { async getFullMenuWithCache(ifModifiedSince?: string): Promise<{ menu: MenuItem; fresh: boolean }> {
if (this.menuCache && this.lastModified && (!ifModifiedSince || new Date(ifModifiedSince) >= this.lastModified)) { if (this.menuCache && this.lastModified && (!ifModifiedSince || new Date(ifModifiedSince) >= this.lastModified)) {
return { menu: this.menuCache, fresh: false }; return { menu: this.menuCache, fresh: false };
} }
await this.loadUserOverrides();
const dynamicItemsPromise = this.generateDynamicItems(); const dynamicItemsPromise = this.generateDynamicItems();
const baseMenu = await this.injectDynamicItems(this.getStaticStructure(), dynamicItemsPromise); 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.menuCache = freshMenu;
this.lastModified = new Date(); this.lastModified = new Date();
@ -48,33 +47,75 @@ export class MenuService {
return !this.lastModified || new Date(ifModifiedSince) < this.lastModified; return !this.lastModified || new Date(ifModifiedSince) < this.lastModified;
} }
private applyOverrides(menu: MenuItem, overrides: Partial<MenuItem>[]): MenuItem { async hideMenuItem(id: string): Promise<void> {
const overrideMap = new Map(overrides.map(o => [o.id, o])); this.userOverrides.set(id, { id, hidden: true });
await this.saveUserOverrides();
this.invalidateCache();
}
const apply = (item: MenuItem): MenuItem => { private applyUserOverrides(menu: MenuItem): MenuItem {
const override = overrideMap.get(item.id); const apply = (item: MenuItem): MenuItem | null => {
const updated = override ? { ...item, ...override } : item; const override = this.userOverrides.get(item.id);
if (override?.hidden) {
return null;
}
const updated: MenuItem = {
...item,
...override,
hidden: undefined
};
if (updated.items) { 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 updated;
}; };
return apply(menu); const result = apply(menu);
return result || { title: menu.title, id: menu.id, items: [] };
} }
private async loadOverrides(): Promise<Partial<MenuItem>[]> { private async loadUserOverrides(): Promise<void> {
try { try {
const content = await fs.readFile(this.menuOverridesPath, 'utf-8'); const content = await fs.readFile(this.userOverridesPath, 'utf-8');
const parsed = JSON.parse(content); const parsed = JSON.parse(content);
return parsed.overrides || [];
this.userOverrides = new Map(
(parsed.overrides || []).map(o => [o.id, o])
);
} catch (e) { } catch (e) {
return []; this.userOverrides = new Map();
await this.saveUserOverrides(); // Создаем файл с пустыми данными
} }
} }
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 { private getStaticStructure(): MenuItem {
return { return {
title: "ЗВКС", title: "ЗВКС",
@ -166,9 +207,12 @@ export class MenuService {
metadataMap: Map<string, string> metadataMap: Map<string, string>
): Promise<MenuItem> { ): Promise<MenuItem> {
const moduleItems = await this.generateModuleItems(device, seriesData, metadataMap); const moduleItems = await this.generateModuleItems(device, seriesData, metadataMap);
const deviceName = metadataMap.get(device) ?? device;
return { return {
id: `device_${device}`, id: `device_${device}`,
title: `Graviton S2082I (${device})`, title: deviceName,
items: moduleItems, items: moduleItems,
isDynamic: true isDynamic: true
}; };
@ -187,12 +231,13 @@ export class MenuService {
private normalizeIdPart(part: string): string { private normalizeIdPart(part: string): string {
return part return part
.replace(/\$/g, '_') .replace(/\$/g, '_')
.replace(/,/g, '_') // Заменяем запятые .replace(/,/g, '_')
.replace(/\s+/g, '_') // Заменяем пробелы .replace(/\s+/g, '_')
.replace(/[^a-zA-Z0-9-_]/g, '') .replace(/[^a-zA-Z0-9-_]/g, '')
.toLowerCase(); .toLowerCase();
} }
// private async generateModuleItems( // private async generateModuleItems(
// device: string, // device: string,
// seriesData: { metric: string; labels: Record<string, string> }[], // seriesData: { metric: string; labels: Record<string, string> }[],
@ -277,9 +322,8 @@ export class MenuService {
seriesData.forEach(({ labels }) => { seriesData.forEach(({ labels }) => {
if (labels.device === device && labels.source_id) { 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')) { if (sourceId.includes(', complex') || sourceId.includes(', integration')) {
const [modulePart, folderType] = sourceId.split(', ').map(s => s.trim()); const [modulePart, folderType] = sourceId.split(', ').map(s => s.trim());
let displayName = modulePart; let displayName = modulePart;
@ -292,19 +336,15 @@ export class MenuService {
displayName = 'Unknown Module'; displayName = 'Unknown Module';
} }
// Сохраняем в специальные папки
if (!specialFolders.has(folderType)) { if (!specialFolders.has(folderType)) {
specialFolders.set(folderType, new Map()); specialFolders.set(folderType, new Map());
} }
specialFolders.get(folderType)!.set(modulePart, displayName); specialFolders.get(folderType)!.set(modulePart, displayName);
} }
// Проверяем старые некорректные форматы (без запятой)
else if (sourceId.endsWith('complex') || sourceId.endsWith('integration')) { else if (sourceId.endsWith('complex') || sourceId.endsWith('integration')) {
// Игнорируем старые некорректные форматы
console.warn(`Ignoring legacy format: ${sourceId} for device ${device}`); console.warn(`Ignoring legacy format: ${sourceId} for device ${device}`);
} }
else { else {
// Обычная обработка
let displayName = sourceId; let displayName = sourceId;
if (sourceId.startsWith('module$')) { if (sourceId.startsWith('module$')) {
displayName = `Module ${sourceId.split('$')[1]}`; displayName = `Module ${sourceId.split('$')[1]}`;
@ -325,7 +365,6 @@ export class MenuService {
}) })
); );
// Создаем специальные папки
const specialFolderItems = Array.from(specialFolders.entries()).map( const specialFolderItems = Array.from(specialFolders.entries()).map(
async ([folderType, folderModules]) => { async ([folderType, folderModules]) => {
const folderItems = await Promise.all( const folderItems = await Promise.all(
@ -333,7 +372,12 @@ export class MenuService {
async ([sourceId, displayName]) => ({ async ([sourceId, displayName]) => ({
id: `module_${device}_${this.normalizeIdPart(sourceId)}_${this.normalizeIdPart(folderType)}`, id: `module_${device}_${this.normalizeIdPart(sourceId)}_${this.normalizeIdPart(folderType)}`,
title: displayName, title: displayName,
items: await this.generateMetricItems(device, `${sourceId}, ${folderType}`, seriesData, metadataMap), items: await this.generateMetricItems(
device,
`${sourceId}, ${folderType}`,
seriesData,
metadataMap
),
isDynamic: true 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( private async generateMetricItems(
device: string, device: string,
module: string, module: string,
@ -362,14 +410,32 @@ export class MenuService {
): Promise<MenuItem[]> { ): Promise<MenuItem[]> {
const ranges = await this.rangeService.getRanges(); const ranges = await this.rangeService.getRanges();
// Фильтруем по device и точному совпадению source_id const normModule = this.normalizeSourceId(module);
const filtered = seriesData.filter( const isPlainModule = !normModule.includes(',');
({ labels }) => labels.device === device && labels.source_id === module
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 uniqueMetrics = new Set(filtered.map(entry => entry.metric));
const safeDevice = this.normalizeIdPart(device); const safeDevice = this.normalizeIdPart(device);
const safeModule = this.normalizeIdPart(module); const safeModule = this.normalizeIdPart(normModule);
return Array.from(uniqueMetrics).map(metric => { return Array.from(uniqueMetrics).map(metric => {
const description = metadataMap.get(metric) || metric; const description = metadataMap.get(metric) || metric;
@ -382,13 +448,13 @@ export class MenuService {
metric, metric,
filters: { filters: {
device, device,
source_id: module source_id: normModule
}, },
ranges: metricRanges, ranges: metricRanges,
isDynamic: true, isDynamic: true,
meta: { meta: {
originalDevice: device, originalDevice: device,
originalModule: module originalModule: normModule
} }
}; };
}); });
@ -415,21 +481,20 @@ export class MenuService {
} }
async updateMenuItem(id: string, update: Partial<MenuItem>): Promise<MenuItem> { async updateMenuItem(id: string, update: Partial<MenuItem>): Promise<MenuItem> {
const { menu: fullMenu } = await this.getFullMenuWithCache(); const existing = this.userOverrides.get(id) || { id };
const item = this.findMenuItem(fullMenu, id); this.userOverrides.set(id, { ...existing, ...update });
if (!item) throw new Error('Menu item not found'); await this.saveUserOverrides();
Object.assign(item, update); this.invalidateCache();
const { menu } = await this.getFullMenuWithCache();
const updated = this.findMenuItem(menu, id);
this.menuCache = null; if (!updated) {
return item; throw new HttpException('Updated item not found', HttpStatus.NOT_FOUND);
} }
async saveOverrides(overrides: Partial<MenuItem>[]): Promise<void> { return updated;
await fs.writeFile(this.menuOverridesPath, JSON.stringify({ overrides }, null, 2), 'utf-8');
this.menuCache = null;
} }
invalidateCache(): void { invalidateCache(): void {