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 { 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<MenuItem>[] }) {
await this.menuService.saveOverrides(data.overrides);
return { status: 'success' };
}
// @Post('overrides')
// async saveOverrides(@Body() data: { overrides: Partial<MenuItem>[] }) {
// await this.menuService.saveOverrides(data.overrides);
// return { status: 'success' };
// }
@Put(':id')
async updateMenuItem(
@Param('id') id: string,
@Body() update: Partial<MenuItem>
) {
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
);
}
}
@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;
status: number;
}>;
hidden?: boolean;
}

View File

@ -10,29 +10,28 @@ export class MenuService {
private menuCache: MenuItem | null = null;
private lastModified: Date | null = null;
private cacheInitialized = false;
private userOverrides: Map<string, Partial<MenuItem>> = new Map();
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.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>[]): MenuItem {
const overrideMap = new Map(overrides.map(o => [o.id, o]));
async hideMenuItem(id: string): Promise<void> {
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<Partial<MenuItem>[]> {
private async loadUserOverrides(): Promise<void> {
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<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 {
return {
title: "ЗВКС",
@ -166,9 +207,12 @@ export class MenuService {
metadataMap: Map<string, string>
): Promise<MenuItem> {
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<string, string> }[],
@ -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<MenuItem[]> {
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<MenuItem>): Promise<MenuItem> {
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<MenuItem>[]): Promise<void> {
await fs.writeFile(this.menuOverridesPath, JSON.stringify({ overrides }, null, 2), 'utf-8');
this.menuCache = null;
return updated;
}
invalidateCache(): void {