added menu editor
parent
d8a8d6b8e5
commit
aaa2482f04
|
|
@ -1,10 +0,0 @@
|
||||||
export interface MenuItem {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
items?: MenuItem[];
|
|
||||||
metric?: string;
|
|
||||||
filters?: {
|
|
||||||
device: string;
|
|
||||||
source_id: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -11,4 +11,5 @@ export interface MenuItem {
|
||||||
max: number;
|
max: number;
|
||||||
status: number;
|
status: number;
|
||||||
}>;
|
}>;
|
||||||
|
hidden?: boolean;
|
||||||
}
|
}
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue