Compare commits

..

No commits in common. "5c73658c2f2cecc16b4a8da839cbd992950ff44f" and "ce363d8b6be71d1bfb15b0de7c4362a712cf0c05" have entirely different histories.

27 changed files with 11470 additions and 12247 deletions

17
.env
View File

@ -1,26 +1,9 @@
#Прометеус #Прометеус
#PROMETHEUS_API=http://192.168.2.34:9090/api/v1 #PROMETHEUS_API=http://192.168.2.34:9090/api/v1
#FRONTEND_URL=192.168.2.39:5173
#Постгресс #Постгресс
#DB_HOST=192.168.2.37 #DB_HOST=192.168.2.37
#DB_PORT=5432 #DB_PORT=5432
#DB_USER=trust #DB_USER=trust
#DB_PASSWORD=kaiqolzp2a4aH #DB_PASSWORD=kaiqolzp2a4aH
#DB_NAME=trust-db #DB_NAME=trust-db
#JWT
#JWT_SECRET=x7F!2p9L#q1$z0*8R5vYgMnBk
#JWT_SECRET=x7Fcdp9L#q1$z0*8R5vYgMnBk
#COOKIE
# Для production
#COOKIE_SECURE=true
#COOKIE_SAME_SITE=strict
# Для development
# COOKIE_SECURE=false
# COOKIE_SAME_SITE=lax

7
.gitignore vendored
View File

@ -52,12 +52,5 @@ pids
*.seed *.seed
*.pid.lock *.pid.lock
# Игнорировать .env файлы
.env
.env.local
.env.development
.env.production
.env.test
# Diagnostic reports (https://nodejs.org/api/report.html) # Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json

22349
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -36,15 +36,7 @@
"@types/bcrypt": "^5.0.2", "@types/bcrypt": "^5.0.2",
"socket.io": "^4.8.1", "socket.io": "^4.8.1",
"@nestjs/websockets": "11.0.12", "@nestjs/websockets": "11.0.12",
"@nestjs/platform-socket.io": "11.0.12", "@nestjs/platform-socket.io": "11.0.12"
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"cookie-parser": "^1.4.7",
"@types/passport-jwt": "^4.0.1",
"@types/cookie-parser": "^1.4.8",
"@nestjs/jwt": "^11.0.0",
"@nestjs/passport": "^11.0.5",
"@nestjs/swagger": "11.1.4"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.2.0", "@eslint/eslintrc": "^3.2.0",

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,10 +1,11 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { HttpModule } from '@nestjs/axios'; import { HttpModule } from '@nestjs/axios';
import { PrometheusService } from './prometheus.service';
import { MetricsController } from './metrics.controller';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { AuthModule } from './auth/auth.module'; import { AuthModule } from './auth/auth.module';
import { MenuModule } from './menu/menu.module'; import { MetricsGateway } from './metrics.gateway';
import { PrometheusModule } from './prometheus.module';
@Module({ @Module({
imports: [ imports: [
@ -25,8 +26,12 @@ import { PrometheusModule } from './prometheus.module';
}), }),
HttpModule, HttpModule,
AuthModule, AuthModule,
PrometheusModule,
MenuModule,
], ],
controllers: [MetricsController],
providers: [
PrometheusService,
MetricsGateway,
],
exports: [MetricsGateway],
}) })
export class AppModule {} export class AppModule { }

View File

@ -1,75 +1,16 @@
import { Controller, Post, Get, Body, Res, Req, UnauthorizedException, UseGuards } from '@nestjs/common'; import { Controller, Post, Body, UnauthorizedException } from '@nestjs/common';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { Response, Request } from 'express';
import { JwtAuthGuard } from './jwt-auth.guard';
import { Logger } from '@nestjs/common';
@Controller('auth') @Controller('auth')
export class AuthController { export class AuthController {
private readonly logger = new Logger(AuthController.name); constructor(private authService: AuthService) { }
constructor(private authService: AuthService) { } @Post('login')
async login(@Body() body: { login: string; password: string }) {
@Get('check') const user = await this.authService.validateUser(body.login, body.password);
@UseGuards(JwtAuthGuard) if (!user) {
async checkAuth(@Req() req: Request) { throw new UnauthorizedException('Неверный логин или пароль');
this.logger.debug(`Проверен запрос на авторизацию. Cookies: ${JSON.stringify(req.cookies)}`); }
return { success: true, user };
if (!req.user) { }
this.logger.warn('Unauthorized access attempt');
throw new UnauthorizedException('Пользователь не аутентифицирован');
}
const user = req.user as { userId: number; username: string; login?: string };
const userWithoutPassword = { ...user };
this.logger.log(`Аутентифицированный пользователь: ${user.username}`);
return {
isAuthenticated: true,
user: userWithoutPassword
};
}
@Post('login')
async login(
@Body() body: { login: string; password: string },
@Res({ passthrough: true }) res: Response,
) {
this.logger.debug(`Login attempt for user: ${body.login}`);
const user = await this.authService.validateUser(body.login, body.password);
if (!user) {
this.logger.warn(`Failed login attempt for user: ${body.login}`);
throw new UnauthorizedException('Неверный логин или пароль');
}
const { access_token } = await this.authService.login(user);
res.cookie('access_token', access_token, {
httpOnly: true,
secure: process.env.COOKIE_SECURE === 'true',
sameSite: (process.env.COOKIE_SAME_SITE as 'strict' | 'lax' | 'none') || 'strict',
maxAge: 3600000,
path: '/',
});
this.logger.log(`User ${body.login} successfully logged in`);
return {
success: true,
user: {
id: user.id,
login: user.login
},
access_token
};
}
@Post('logout')
@UseGuards(JwtAuthGuard)
async logout(@Res({ passthrough: true }) res: Response, @Req() req: Request) {
const user = req.user as { userId: number; username: string };
this.logger.log(`User ${user.username} logging out`);
res.clearCookie('access_token');
return { success: true };
}
} }

View File

@ -1,39 +0,0 @@
/* import { ApiProperty } from '@nestjs/swagger';
export class LoginDto {
@ApiProperty({
example: 'admin@example.com',
description: 'User email or login',
required: true
})
login: string;
@ApiProperty({
example: 'yourStrongPassword123',
description: 'User password',
required: true,
minLength: 6
})
password: string;
}
export class CheckAuthResponse {
@ApiProperty({ example: true, description: 'Статус аутентификации' })
isAuthenticated: boolean;
@ApiProperty({
example: { userId: 1, username: 'admin', login: 'admin@example.com' },
description: 'Пользовательская информация без конфиденциальных данных'
})
user: {
userId: number;
username: string;
login?: string;
};
}
export class LogoutResponse {
@ApiProperty({ example: true, description: 'Статус успешного выхода из системы' })
success: boolean;
}
*/

View File

@ -1,12 +0,0 @@
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Observable } from 'rxjs';
@Injectable()
export class AuthGuard implements CanActivate {
canActivate(
context: ExecutionContext,
): boolean | Promise<boolean> | Observable<boolean> {
const request = context.switchToHttp().getRequest();
return !!request.user;
}
}

View File

@ -1,27 +1,12 @@
import { Module, MiddlewareConsumer } from '@nestjs/common'; // Добавлен импорт MiddlewareConsumer import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { AuthController } from './auth.controller'; import { AuthController } from './auth.controller';
import { User } from './user.entity'; import { User } from './user.entity';
import { JwtStrategy } from './jwt.strategy';
import * as cookieParser from 'cookie-parser';
@Module({ @Module({
imports: [ imports: [TypeOrmModule.forFeature([User])],
TypeOrmModule.forFeature([User]),
PassportModule,
JwtModule.register({
secret: process.env.JWT_SECRET || 'your-secret-key',
signOptions: { expiresIn: '1h' },
}),
],
controllers: [AuthController], controllers: [AuthController],
providers: [AuthService, JwtStrategy], providers: [AuthService],
}) })
export class AuthModule { export class AuthModule { }
configure(consumer: MiddlewareConsumer) {
consumer.apply(cookieParser()).forRoutes('*');
}
}

View File

@ -1,5 +1,4 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { User } from './user.entity'; import { User } from './user.entity';
@ -9,26 +8,20 @@ export class AuthService {
constructor( constructor(
@InjectRepository(User) @InjectRepository(User)
private usersRepository: Repository<User>, private usersRepository: Repository<User>,
private jwtService: JwtService,
) { } ) { }
async validateUser(login: string, password: string): Promise<any> { async validateUser(login: string, password: string): Promise<any> {
// Ищем пользователя по login
const user = await this.usersRepository.findOne({ where: { login } }); const user = await this.usersRepository.findOne({ where: { login } });
// Проверяем, что нашли пользователя и пароль совпадает
if (user && user.password === password) { if (user && user.password === password) {
const { password, ...result } = user; const { password, ...result } = user;
return result; return result;
} }
return null; return null;
} }
async login(user: any) {
const payload = {
username: user.login,
sub: user.id
};
return {
access_token: this.jwtService.sign(payload),
};
}
} }

View File

@ -1,28 +0,0 @@
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Observable } from 'rxjs';
import { JwtService } from '@nestjs/jwt';
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(private jwtService: JwtService) {}
canActivate(context: ExecutionContext): boolean | Promise<boolean> | Observable<boolean> {
const request = context.switchToHttp().getRequest();
try {
const token = this.extractToken(request);
if (!token) return false;
const payload = this.jwtService.verify(token);
request.user = payload;
return true;
} catch (e) {
return false;
}
}
private extractToken(request): string | null {
return request.cookies?.access_token ||
request.headers.authorization?.split(' ')[1] ||
null;
}
}

View File

@ -1,28 +0,0 @@
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { Request } from 'express';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor() {
super({
jwtFromRequest: ExtractJwt.fromExtractors([
(request: Request) => {
return request?.cookies?.access_token ||
request?.headers?.authorization?.split(' ')[1];
},
]),
ignoreExpiration: false,
secretOrKey: process.env.JWT_SECRET || 'your-secret-key',
});
}
async validate(payload: any) {
return {
userId: payload.sub,
username: payload.username,
login: payload.username
};
}
}

View File

@ -1,59 +1,20 @@
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { Logger } from '@nestjs/common';
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create(AppModule); const app = await NestFactory.create(AppModule);
const logger = new Logger('Bootstrap');
// Установка глобального префикса для всех маршрутов // Установка глобального префикса для всех маршрутов
app.setGlobalPrefix('api'); app.setGlobalPrefix('api');
// Настройка Swagger //настройка CORS
const config = new DocumentBuilder()
.setTitle('МУФ API')
.setDescription('API для сбора метрик и аутентификации')
.setVersion('1.0')
.addBearerAuth(
{
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
name: 'JWT',
description: 'Enter JWT token',
in: 'header',
},
'JWT-auth', // Это имя для схемы безопасности
)
.addCookieAuth('access_token') // Для cookie-based аутентификации
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api/docs', app, document, {
swaggerOptions: {
persistAuthorization: true,
tagsSorter: 'alpha',
operationsSorter: 'alpha',
docExpansion: 'none',
filter: true,
},
customSiteTitle: 'MSF API Documentation',
});
// Настройка CORS
app.enableCors({ app.enableCors({
origin: [process.env.FRONTEND_URL, "http://dev.msf.enode"], origin: '*',
credentials: true,
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS', methods: 'GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS',
allowedHeaders: 'Content-Type, Authorization, X-Requested-With', allowedHeaders: 'Content-Type, Authorization',
exposedHeaders: 'Authorization',
preflightContinue: false,
optionsSuccessStatus: 204
}); });
await app.listen(process.env.PORT ?? 3000);
const port = process.env.PORT ?? 3000;
await app.listen(port);
logger.log(`Application is running on: http://localhost:${port}/api/docs`);
} }
bootstrap(); bootstrap();

View File

@ -1,5 +0,0 @@
export interface MetricMetadata {
metric: string;
type: string;
help: string;
}

View File

@ -1,37 +0,0 @@
import { Controller, Get, Param, Post, Body, Put } from '@nestjs/common';
import { MenuService } from './menu.service';
import { MenuItem } from './menu.interface';
@Controller('menu')
export class MenuController {
constructor(private readonly menuService: MenuService) { }
@Get()
async getMenu(): Promise<MenuItem> {
return this.menuService.getFullMenu();
}
@Post('save')
async saveMenu() {
await this.menuService.saveMenuToFile();
return { status: 'saved' };
}
@Get('full')
async getFullMenu(): Promise<MenuItem> {
return this.menuService.getFullMenu();
}
@Post('overrides')
async saveOverrides(@Body() data: { overrides: Partial<MenuItem>[] }) {
return this.menuService.saveOverrides(data.overrides);
}
@Put(':id')
async updateMenuItem(
@Param('id') id: string,
@Body() update: Partial<MenuItem>
): Promise<MenuItem> {
return this.menuService.updateMenuItem(id, update);
}
}

View File

@ -1,9 +0,0 @@
export interface MenuItem {
title: string;
id: string;
items?: MenuItem[];
metric?: string;
filters?: Record<string, string>;
isDynamic?: boolean;
templateId?: string;
}

View File

@ -1,11 +0,0 @@
import { Module } from '@nestjs/common';
import { MenuController } from './menu.controller';
import { MenuService } from './menu.service';
import { PrometheusModule } from '../prometheus.module'; // Импортируем PrometheusModule
@Module({
imports: [PrometheusModule], // Добавляем в imports
controllers: [MenuController],
providers: [MenuService]
})
export class MenuModule {}

View File

@ -1,239 +0,0 @@
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';
@Injectable()
export class MenuService {
constructor(private readonly prometheusService: PrometheusService) { }
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 dynamicItems = await this.generateDynamicItems();
const baseMenu = this.injectDynamicItems(this.getStaticStructure(), dynamicItems);
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>(); // metric -> help
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');
return devices.map(device => ({
id: `device_${device}`,
title: `Graviton S2082I (${device})`,
items: this.generateModuleItems(device, allSeries, metadataMap),
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 generateModuleItems(
device: string,
seriesData: { metric: string; labels: Record<string, string> }[],
metadataMap: Map<string, string>
): MenuItem[] {
const modules = new Set<string>();
seriesData.forEach(({ labels }) => {
if (labels.device === device && labels.source_id) {
modules.add(labels.source_id);
}
});
return Array.from(modules).map(module => ({
id: `module_${device}_${module}`,
title: `Module ${module.replace('module$', '')}`,
items: this.generateMetricItems(device, module, seriesData, metadataMap),
isDynamic: true
}));
}
private generateMetricItems(
device: string,
module: string,
seriesData: { metric: string; labels: Record<string, string> }[],
metadataMap: Map<string, string>
): MenuItem[] {
const filtered = seriesData.filter(
({ labels }) => labels.device === device && labels.source_id === module
);
const uniqueMetrics = new Set(filtered.map(entry => entry.metric));
return Array.from(uniqueMetrics).map(metric => {
const description = metadataMap.get(metric) || metric;
return {
id: `metric_${device}_${module}_${metric}`,
title: description,
metric,
filters: {
device,
source_id: module
},
isDynamic: true
};
});
}
private injectDynamicItems(menu: MenuItem, dynamicItems: MenuItem[]): MenuItem {
if (menu.id === 'media_servers') {
return { ...menu, items: dynamicItems };
}
return {
...menu,
items: menu.items?.map(item => this.injectDynamicItems(item, dynamicItems)) || []
};
}
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;
}
}

View File

@ -1,21 +1,11 @@
import { Controller, Get, Query } from '@nestjs/common'; import { Controller, Get, Query } from '@nestjs/common';
import { PrometheusService } from './prometheus.service'; import { PrometheusService } from './prometheus.service';
import { ApiTags, ApiOperation, ApiResponse, ApiQuery } from '@nestjs/swagger';
@ApiTags('Metrics - HTTP')
@Controller('metrics') @Controller('metrics')
export class MetricsController { export class MetricsController {
constructor(private readonly prometheusService: PrometheusService) { } constructor(private readonly prometheusService: PrometheusService) { }
@Get() @Get()
@ApiOperation({ summary: 'Получиние данных по конкретной метрике' })
@ApiQuery({ name: 'metric', required: true, description: 'Имя метрики для извлечения' })
@ApiQuery({ name: 'start', required: false, description: 'Начальная временная метка для запроса диапазона' })
@ApiQuery({ name: 'end', required: false, description: 'Конечная временная метка для запроса диапазона' })
@ApiQuery({ name: 'step', required: false, description: 'Размер шага для запроса диапазона' })
@ApiResponse({ status: 200, description: 'Успешно получены метрические данные' })
@ApiResponse({ status: 400, description: 'Указанные недопустимые параметры' })
@ApiResponse({ status: 500, description: 'Внутренняя ошибка сервера' })
async getMetrics( async getMetrics(
@Query('metric') metric: string, @Query('metric') metric: string,
@Query('start') start: number, @Query('start') start: number,
@ -29,15 +19,11 @@ export class MetricsController {
} }
@Get('/all') @Get('/all')
@ApiOperation({ summary: 'Получение списка всех метрик' })
@ApiResponse({ status: 200, description: 'Список извлеченных метрик' })
async getAllMetrics() { async getAllMetrics() {
return this.prometheusService.fetchAllMetrics(); return this.prometheusService.fetchAllMetrics();
} }
@Get('/all-values') @Get('/all-values')
@ApiOperation({ summary: 'Получение списка всех метрик и их значения' })
@ApiResponse({ status: 200, description: 'Все метрики с полученными значениями' })
async getAllMetricsWithValues() { async getAllMetricsWithValues() {
return this.prometheusService.fetchAllMetricsWithValues(); return this.prometheusService.fetchAllMetricsWithValues();
} }

View File

@ -1,189 +1,127 @@
import { import { WebSocketGateway, WebSocketServer, OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect, SubscribeMessage, } from '@nestjs/websockets';
WebSocketGateway,
WebSocketServer,
OnGatewayInit,
OnGatewayConnection,
OnGatewayDisconnect,
SubscribeMessage,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io'; import { Server, Socket } from 'socket.io';
import { PrometheusService } from './prometheus.service'; import { PrometheusService } from './prometheus.service';
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
@WebSocketGateway({ @WebSocketGateway({
cors: { /*
origin: process.env.FRONTEND_URL, cors: {
methods: ['GET', 'POST'], origin: '*', // В production укажите конкретные домены
credentials: true methods: ['GET', 'POST'],
}, credentials: true
}, */
namespace: '/api/metrics-ws' namespace: '/api/metrics-ws'
}) })
export class MetricsGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect { export class MetricsGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect {
@WebSocketServer() server: Server; @WebSocketServer() server: Server;
private readonly logger = new Logger(MetricsGateway.name); private readonly logger = new Logger(MetricsGateway.name);
private activeSockets: Map<string, Socket> = new Map(); private activeSockets: Map<string, Socket> = new Map();
private metricSubscriptions = new Map<string, {
stopUpdates: () => void;
clients: Set<string>;
}>();
constructor(private readonly prometheusService: PrometheusService) { } constructor(private readonly prometheusService: PrometheusService) { }
afterInit(server: Server) { afterInit(server: Server) {
this.logger.log('WebSocket Gateway initialized'); this.logger.log('WebSocket Gateway initialized');
this.logger.log('WebSocket server initialized successfully');
} }
handleConnection(client: Socket) { handleConnection(client: Socket) {
this.logger.log(`Client connected: ${client.id}`); this.logger.log(`Client connected: ${client.id}`);
this.logger.log(`New client connected: ${client.id} from ${client.handshake.address}`);
this.activeSockets.set(client.id, client); this.activeSockets.set(client.id, client);
} }
handleDisconnect(client: Socket) { handleDisconnect(client: Socket) {
this.logger.log(`Client disconnected: ${client.id}`); this.logger.log(`Client disconnected: ${client.id}`);
this.activeSockets.delete(client.id); this.activeSockets.delete(client.id);
// Очистка всех подписок этого клиента
for (const [metric, subscription] of this.metricSubscriptions) {
subscription.clients.delete(client.id);
if (subscription.clients.size === 0) {
subscription.stopUpdates();
this.metricSubscriptions.delete(metric);
}
}
}
@SubscribeMessage('unsubscribe-all')
handleUnsubscribeAll(client: Socket) {
for (const [metric, subscription] of this.metricSubscriptions) {
subscription.clients.delete(client.id);
if (subscription.clients.size === 0) {
subscription.stopUpdates();
this.metricSubscriptions.delete(metric);
}
}
} }
@SubscribeMessage('get-metrics') @SubscribeMessage('get-metrics')
async handleGetMetrics(client: Socket, payload: any) { async handleGetMetrics(client: Socket, payload: any) {
const { metric, start, end, step, isRangeQuery, requestId, filters = {} } = payload; const { metric, start, end, step, _t } = payload;
this.logger.log(`Received metrics request: ${metric}, start: ${start}, end: ${end}, step: ${step}`);
if (!metric) {
client.emit('metrics-error', {
error: 'Metric name is required',
requestId
});
return;
}
if (isRangeQuery) {
try {
const data = await this.prometheusService.fetchMetricsRange(metric, start, end, step, filters);
client.emit('metrics-data', { metric, data, requestId });
return;
} catch (error) {
client.emit('metrics-error', {
error: error.message,
requestId
});
return;
}
}
try { try {
// Для запросов с диапазоном - просто возвращаем данные без подписки
if (start && end) {
const data = await this.prometheusService.fetchMetricsRange(metric, start, end, step);
client.emit('metrics-data', { metric, data });
return;
}
// Для запросов без диапазона (realtime) - запускаем подписку
const stopUpdates = await this.sendPeriodicUpdates( const stopUpdates = await this.sendPeriodicUpdates(
metric, metric,
step || 5000, step || 5000, // Используем переданный шаг или дефолтный
(data) => { client
client.emit('metrics-data', { metric, data, requestId });
},
filters
); );
const cleanup = () => { client.on('disconnect', () => stopUpdates());
stopUpdates(); client.on('unsubscribe-metric', () => stopUpdates());
client.off('disconnect', cleanup);
client.off('unsubscribe-metric', cleanup);
};
client.on('disconnect', cleanup);
client.on('unsubscribe-metric', cleanup);
} catch (error) { } catch (error) {
this.logger.error(`Error fetching metrics: ${error.message}`);
client.emit('metrics-error', { client.emit('metrics-error', {
error: error.message, metric,
requestId error: error.message
}); });
} }
} }
private getSubscriptionKey(metric: string, filters: Record<string, string>): string { @SubscribeMessage('get-metric-types')
// Создаём уникальный ключ на основе метрики и фильтров async handleGetMetricTypes(client: Socket, payload: { metric: string }) {
const filterKeys = Object.keys(filters).sort(); try {
const filterString = filterKeys.map(k => `${k}=${filters[k]}`).join('&'); const type = await this.prometheusService.fetchMetricType(payload.metric);
return `${metric}${filterString ? `?${filterString}` : ''}`; const description = await this.prometheusService.fetchMetricDescription(payload.metric);
client.emit('metric-types', {
metric: payload.metric,
type,
description
});
} catch (error) {
this.logger.log(`Error fetching metric types: ${error.message}`);
client.emit('metrics-error', {
metric: payload.metric,
error: error.message
});
}
} }
@SubscribeMessage('get-all-metrics')
async handleGetAllMetrics(client: Socket) {
try {
const metrics = await this.prometheusService.fetchAllMetrics();
client.emit('all-metrics', metrics);
} catch (error) {
this.logger.log(`Error fetching all metrics: ${error.message}`);
client.emit('metrics-error', {
error: error.message
});
}
}
@SubscribeMessage('subscribe-metric') @SubscribeMessage('subscribe-metric')
async handleSubscribeMetric( async handleSubscribeMetric(client: Socket, payload: { metric: string, interval?: number }) {
client: Socket, const stopUpdates = await this.sendPeriodicUpdates(
payload: { payload.metric,
metric: string; payload.interval || 5000, // Добавляем значение по умолчанию
interval?: number; client // Передаем клиента
filters?: Record<string, string>; );
}
) {
const { metric, interval = 5000, filters = {} } = payload;
const subscriptionKey = this.getSubscriptionKey(metric, filters);
if (!this.metricSubscriptions.has(subscriptionKey)) { // Сохраняем функцию остановки для этого клиента
const stopUpdates = await this.sendPeriodicUpdates( client.on('disconnect', () => stopUpdates());
metric, client.on('unsubscribe-metric', () => stopUpdates());
interval,
(data) => {
// Отправляем только подписчикам этой конкретной метрики с фильтрами
this.server.emit('metrics-data', {
metric: subscriptionKey,
data
});
},
filters
);
this.metricSubscriptions.set(subscriptionKey, {
stopUpdates,
clients: new Set([client.id])
});
} else {
this.metricSubscriptions.get(subscriptionKey)?.clients.add(client.id);
}
const unsubscribe = () => {
const subscription = this.metricSubscriptions.get(subscriptionKey);
if (subscription) {
subscription.clients.delete(client.id);
if (subscription.clients.size === 0) {
subscription.stopUpdates();
this.metricSubscriptions.delete(subscriptionKey);
}
}
};
client.on('disconnect', unsubscribe);
client.on('unsubscribe-metric', unsubscribe);
} }
async sendPeriodicUpdates( // Метод для периодической отправки обновлений
metric: string, async sendPeriodicUpdates(metric: string, interval: number, client: Socket) {
interval: number,
callback: (data: any) => void,
filters: Record<string, string> = {}
) {
const timer = setInterval(async () => { const timer = setInterval(async () => {
try { try {
const data = await this.prometheusService.fetchMetricsWithFilters(metric, filters); const data = await this.prometheusService.fetchMetrics(metric);
callback(data); client.emit('metrics-data', { metric, data });
} catch (error) { } catch (error) {
this.logger.error(`Error in periodic update for ${metric}:`, error.message); this.logger.error(`Error in periodic update for ${metric}: ${error.message}`);
} }
}, interval); }, interval);

View File

@ -1,9 +1,8 @@
export interface PrometheusMetric { export interface PrometheusMetric {
__name__: string; __name__?: string;
device: string; [key: string]: string | number | undefined;
source_id: string; timestamp: number;
value: number; value: number;
timestamp: number; type: string; // Тип метрики ("gauge", "counter", и т. д.)
type?: string; description?: string; // Описание метрики
description?: string;
} }

View File

@ -1,13 +0,0 @@
import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { PrometheusService } from './prometheus.service';
import { MetricsController } from './metrics.controller';
import { MetricsGateway } from './metrics.gateway';
@Module({
imports: [HttpModule],
providers: [PrometheusService, MetricsGateway],
controllers: [MetricsController],
exports: [PrometheusService]
})
export class PrometheusModule {}

View File

@ -3,7 +3,6 @@ import { HttpService } from '@nestjs/axios';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { lastValueFrom } from 'rxjs'; import { lastValueFrom } from 'rxjs';
import { PrometheusMetric } from './prometheus-metric.interface'; import { PrometheusMetric } from './prometheus-metric.interface';
import { MenuItem } from './menu/menu.interface';
@Injectable() @Injectable()
export class PrometheusService { export class PrometheusService {
@ -17,6 +16,7 @@ export class PrometheusService {
console.log('Prometheus API URL:', this.prometheusUrl); console.log('Prometheus API URL:', this.prometheusUrl);
} }
// Получаем тип метрики
async fetchMetricType(metric: string): Promise<string | null> { async fetchMetricType(metric: string): Promise<string | null> {
try { try {
const response = await lastValueFrom( const response = await lastValueFrom(
@ -24,6 +24,7 @@ export class PrometheusService {
params: { metric }, params: { metric },
}) })
); );
const metadata = response.data.data[metric]; const metadata = response.data.data[metric];
return metadata?.length ? metadata[0].type : null; return metadata?.length ? metadata[0].type : null;
} catch (error) { } catch (error) {
@ -32,6 +33,7 @@ export class PrometheusService {
} }
} }
// Получаем описание метрики
async fetchMetricDescription(metric: string): Promise<string | undefined> { async fetchMetricDescription(metric: string): Promise<string | undefined> {
try { try {
const response = await lastValueFrom( const response = await lastValueFrom(
@ -39,6 +41,7 @@ export class PrometheusService {
params: { metric }, params: { metric },
}) })
); );
const metadata = response.data.data[metric]; const metadata = response.data.data[metric];
return metadata?.length ? metadata[0].help : undefined; return metadata?.length ? metadata[0].help : undefined;
} catch (error) { } catch (error) {
@ -47,176 +50,54 @@ export class PrometheusService {
} }
} }
// Получаем данные метрики (текущие значения)
async fetchMetrics(metric: string): Promise<PrometheusMetric[]> { async fetchMetrics(metric: string): Promise<PrometheusMetric[]> {
try { const response = await lastValueFrom(
const response = await lastValueFrom( this.httpService.get(`${this.prometheusUrl}/query`, {
this.httpService.get(`${this.prometheusUrl}/query`, { params: { query: metric },
params: { query: metric }, })
}) );
);
const metricType = await this.fetchMetricType(metric); const metricType = await this.fetchMetricType(metric);
const metricDescription = await this.fetchMetricDescription(metric); const metricDescription = await this.fetchMetricDescription(metric);
return response.data.data.result.map((entry): PrometheusMetric => ({ return response.data.data.result.map((entry): PrometheusMetric => ({
__name__: entry.metric.__name__ || metric, ...entry.metric,
device: entry.metric.device, timestamp: entry.value[0] * 1000,
instance: entry.metric.instance, value: parseFloat(entry.value[1]),
job: entry.metric.job, type: metricType || 'unknown',
source_id: entry.metric.source_id, description: metricDescription, // Добавляем описание
status: entry.metric.status || '0', }));
timestamp: entry.value[0] * 1000,
value: parseFloat(entry.value[1]),
type: metricType || 'gauge',
description: metricDescription,
...entry.metric
}));
} catch (error) {
console.error(`Error fetching metrics for ${metric}:`, error);
throw error;
}
} }
async fetchMetricsWithFilters(metric: string, filters: Record<string, string>): Promise<PrometheusMetric[]> { // Получаем данные метрики за интервал
try { async fetchMetricsRange(metric: string, start: number, end: number, step: number): Promise<PrometheusMetric[]> {
const query = this.buildFilteredQuery(metric, filters); const response = await lastValueFrom(
const response = await lastValueFrom( this.httpService.get(`${this.prometheusUrl}/query_range`, {
this.httpService.get(`${this.prometheusUrl}/query`, { params: {
params: { query } query: metric,
}) start,
); end,
step,
},
})
);
const metricType = await this.fetchMetricType(metric); const metricType = await this.fetchMetricType(metric);
const metricDescription = await this.fetchMetricDescription(metric); const metricDescription = await this.fetchMetricDescription(metric);
return response.data.data.result.map((entry): PrometheusMetric => ({ return response.data.data.result.flatMap((entry) =>
__name__: entry.metric.__name__ || metric, entry.values.map((value): PrometheusMetric => ({
device: entry.metric.device, ...entry.metric,
instance: entry.metric.instance, timestamp: value[0] * 1000,
job: entry.metric.job, value: parseFloat(value[1]),
source_id: entry.metric.source_id, type: metricType || 'unknown',
status: entry.metric.status || '0', description: metricDescription, // Добавляем описание
timestamp: entry.value[0] * 1000, }))
value: parseFloat(entry.value[1]), );
type: metricType || 'gauge',
description: metricDescription,
...entry.metric
}));
} catch (error) {
console.error(`Error fetching metrics with filters for ${metric}:`, error);
throw error;
}
}
private buildFilteredQuery(metric: string, filters: Record<string, string>): string {
const filterParts = Object.entries(filters)
.filter(([_, value]) => value !== undefined && value !== null && value !== "")
.map(([key, value]) => {
if (key === 'source_id' && !value.startsWith('module$')) {
return `${key}="module$${value}"`;
}
return `${key}="${value}"`;
});
return filterParts.length > 0
? `${metric}{${filterParts.join(',')}}`
: metric;
}
async fetchMetricsRange(metric: string, start: number, end: number, step: number, filters: Record<string, string> = {}): Promise<PrometheusMetric[]> {
const query = this.buildFilteredQuery(metric, filters);
try {
const response = await lastValueFrom(
this.httpService.get(`${this.prometheusUrl}/query_range`, {
params: {
query,
start,
end,
step: step.toString()
},
})
);
const metricType = await this.fetchMetricType(metric);
const metricDescription = await this.fetchMetricDescription(metric);
return response.data.data.result.flatMap((entry) =>
entry.values.map((value): PrometheusMetric => ({
__name__: entry.metric.__name__ || metric,
device: entry.metric.device,
instance: entry.metric.instance,
job: entry.metric.job,
source_id: entry.metric.source_id,
status: entry.metric.status || '0',
timestamp: value[0] * 1000,
value: parseFloat(value[1]),
type: metricType || 'gauge',
description: metricDescription,
...entry.metric
}))
);
} catch (error) {
console.error('Error in fetchMetricsRange:', {
error: error.response?.data || error.message,
query,
filters
});
throw error;
}
}
async getMetricsForMenuItem(menuItem: MenuItem): Promise<PrometheusMetric[]> {
if (!menuItem.metric || !menuItem.filters) {
throw new Error('MenuItem is not a metric item');
}
return this.fetchMetricsWithFilters(menuItem.metric, menuItem.filters);
}
// ✅ Новый метод: получает базовое описание метрики (help, type)
async fetchMetricMetadata(metric: string): Promise<{
name: string;
help?: string;
type?: string;
}> {
try {
const response = await lastValueFrom(
this.httpService.get(`${this.prometheusUrl}/metadata`, {
params: { metric }
})
);
const data = response.data?.data?.[metric]?.[0];
return {
name: metric,
help: data?.help,
type: data?.type
};
} catch (error) {
console.error(`Error fetching metadata for ${metric}:`, error);
return {
name: metric
};
}
}
// ✅ Новый метод: получает ВСЕ серии метрики (все комбинации label-ов)
async fetchMetricSeries(metric: string): Promise<Record<string, string>[]> {
try {
const response = await lastValueFrom(
this.httpService.get(`${this.prometheusUrl}/series`, {
params: { 'match[]': metric }
})
);
return response.data.data || [];
} catch (error) {
console.error(`Error fetching series for ${metric}:`, error);
return [];
}
} }
// Получаем список всех метрик
async fetchAllMetrics(): Promise<string[]> { async fetchAllMetrics(): Promise<string[]> {
const response = await lastValueFrom( const response = await lastValueFrom(
this.httpService.get(`${this.prometheusUrl}/label/__name__/values`) this.httpService.get(`${this.prometheusUrl}/label/__name__/values`)
@ -224,6 +105,7 @@ export class PrometheusService {
return response.data.data; return response.data.data;
} }
// Получаем все метрики с их значениями
async fetchAllMetricsWithValues(): Promise<any[]> { async fetchAllMetricsWithValues(): Promise<any[]> {
const metricNames = await this.fetchAllMetrics(); const metricNames = await this.fetchAllMetrics();
const promises = metricNames.map(async (metric) => { const promises = metricNames.map(async (metric) => {

View File

@ -16,9 +16,6 @@
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"noImplicitAny": false, "noImplicitAny": false,
"strictBindCallApply": false, "strictBindCallApply": false,
"noFallthroughCasesInSwitch": false, "noFallthroughCasesInSwitch": false
"types": [
"node"
]
} }
} }