swagger #19

Merged
Ghost merged 16 commits from swagger into rc 2025-05-28 14:48:18 +03:00
27 changed files with 12247 additions and 11470 deletions

17
.env
View File

@ -1,9 +1,26 @@
#Прометеус #Прометеус
#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,5 +52,12 @@ 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

1
package-lock.json generated
View File

@ -11304,3 +11304,4 @@
} }
} }
} }

View File

@ -36,7 +36,15 @@
"@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",

10
src/MenuItem.interface.ts Normal file
View File

@ -0,0 +1,10 @@
export interface MenuItem {
id: string;
title: string;
items?: MenuItem[];
metric?: string;
filters?: {
device: string;
source_id: string;
};
}

View File

View File

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

View File

@ -1,16 +1,75 @@
import { Controller, Post, Body, UnauthorizedException } from '@nestjs/common'; import { Controller, Post, Get, Body, Res, Req, UnauthorizedException, UseGuards } 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) { }
@Get('check')
@UseGuards(JwtAuthGuard)
async checkAuth(@Req() req: Request) {
this.logger.debug(`Проверен запрос на авторизацию. Cookies: ${JSON.stringify(req.cookies)}`);
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') @Post('login')
async login(@Body() body: { login: string; password: string }) { 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); const user = await this.authService.validateUser(body.login, body.password);
if (!user) { if (!user) {
this.logger.warn(`Failed login attempt for user: ${body.login}`);
throw new UnauthorizedException('Неверный логин или пароль'); throw new UnauthorizedException('Неверный логин или пароль');
} }
return { success: true, user };
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 };
} }
} }

39
src/auth/auth.dto.ts Normal file
View File

@ -0,0 +1,39 @@
/* 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;
}
*/

12
src/auth/auth.guard.ts Normal file
View File

@ -0,0 +1,12 @@
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,12 +1,27 @@
import { Module } from '@nestjs/common'; import { Module, MiddlewareConsumer } from '@nestjs/common'; // Добавлен импорт MiddlewareConsumer
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: [TypeOrmModule.forFeature([User])], imports: [
TypeOrmModule.forFeature([User]),
PassportModule,
JwtModule.register({
secret: process.env.JWT_SECRET || 'your-secret-key',
signOptions: { expiresIn: '1h' },
}),
],
controllers: [AuthController], controllers: [AuthController],
providers: [AuthService], providers: [AuthService, JwtStrategy],
}) })
export class AuthModule { } export class AuthModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(cookieParser()).forRoutes('*');
}
}

View File

@ -1,4 +1,5 @@
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';
@ -8,20 +9,26 @@ 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

@ -0,0 +1,28 @@
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;
}
}

28
src/auth/jwt.strategy.ts Normal file
View File

@ -0,0 +1,28 @@
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,20 +1,59 @@
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');
//настройка CORS // Настройка Swagger
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();
app.enableCors({ const document = SwaggerModule.createDocument(app, config);
origin: '*', SwaggerModule.setup('api/docs', app, document, {
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS', swaggerOptions: {
allowedHeaders: 'Content-Type, Authorization', persistAuthorization: true,
tagsSorter: 'alpha',
operationsSorter: 'alpha',
docExpansion: 'none',
filter: true,
},
customSiteTitle: 'MSF API Documentation',
}); });
await app.listen(process.env.PORT ?? 3000);
// Настройка CORS
app.enableCors({
origin: [process.env.FRONTEND_URL, "http://dev.msf.enode"],
credentials: true,
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS',
allowedHeaders: 'Content-Type, Authorization, X-Requested-With',
exposedHeaders: 'Authorization',
preflightContinue: false,
optionsSuccessStatus: 204
});
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

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

View File

View File

@ -0,0 +1,37 @@
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

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

11
src/menu/menu.module.ts Normal file
View File

@ -0,0 +1,11 @@
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 {}

239
src/menu/menu.service.ts Normal file
View File

@ -0,0 +1,239 @@
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,11 +1,21 @@
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,
@ -19,11 +29,15 @@ 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,127 +1,189 @@
import { WebSocketGateway, WebSocketServer, OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect, SubscribeMessage, } from '@nestjs/websockets'; import {
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: { cors: {
origin: '*', // В production укажите конкретные домены origin: process.env.FRONTEND_URL,
methods: ['GET', 'POST'], methods: ['GET', 'POST'],
credentials: true 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, _t } = payload; const { metric, start, end, step, isRangeQuery, requestId, filters = {} } = payload;
this.logger.log(`Received metrics request: ${metric}, start: ${start}, end: ${end}, step: ${step}`);
try { if (!metric) {
// Для запросов с диапазоном - просто возвращаем данные без подписки client.emit('metrics-error', {
if (start && end) { error: 'Metric name is required',
const data = await this.prometheusService.fetchMetricsRange(metric, start, end, step); requestId
client.emit('metrics-data', { metric, data }); });
return; return;
} }
// Для запросов без диапазона (realtime) - запускаем подписку 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 {
const stopUpdates = await this.sendPeriodicUpdates( const stopUpdates = await this.sendPeriodicUpdates(
metric, metric,
step || 5000, // Используем переданный шаг или дефолтный step || 5000,
client (data) => {
client.emit('metrics-data', { metric, data, requestId });
},
filters
); );
client.on('disconnect', () => stopUpdates()); const cleanup = () => {
client.on('unsubscribe-metric', () => stopUpdates()); 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', {
metric, error: error.message,
error: error.message requestId
}); });
} }
} }
@SubscribeMessage('get-metric-types') private getSubscriptionKey(metric: string, filters: Record<string, string>): string {
async handleGetMetricTypes(client: Socket, payload: { metric: string }) { // Создаём уникальный ключ на основе метрики и фильтров
try { const filterKeys = Object.keys(filters).sort();
const type = await this.prometheusService.fetchMetricType(payload.metric); const filterString = filterKeys.map(k => `${k}=${filters[k]}`).join('&');
const description = await this.prometheusService.fetchMetricDescription(payload.metric); return `${metric}${filterString ? `?${filterString}` : ''}`;
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(client: Socket, payload: { metric: string, interval?: number }) { async handleSubscribeMetric(
client: Socket,
payload: {
metric: string;
interval?: number;
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( const stopUpdates = await this.sendPeriodicUpdates(
payload.metric, metric,
payload.interval || 5000, // Добавляем значение по умолчанию interval,
client // Передаем клиента (data) => {
// Отправляем только подписчикам этой конкретной метрики с фильтрами
this.server.emit('metrics-data', {
metric: subscriptionKey,
data
});
},
filters
); );
// Сохраняем функцию остановки для этого клиента this.metricSubscriptions.set(subscriptionKey, {
client.on('disconnect', () => stopUpdates()); stopUpdates,
client.on('unsubscribe-metric', () => stopUpdates()); clients: new Set([client.id])
});
} else {
this.metricSubscriptions.get(subscriptionKey)?.clients.add(client.id);
} }
// Метод для периодической отправки обновлений const unsubscribe = () => {
async sendPeriodicUpdates(metric: string, interval: number, client: Socket) { 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,
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.fetchMetrics(metric); const data = await this.prometheusService.fetchMetricsWithFilters(metric, filters);
client.emit('metrics-data', { metric, data }); callback(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,8 +1,9 @@
export interface PrometheusMetric { export interface PrometheusMetric {
__name__?: string; __name__: string;
[key: string]: string | number | undefined; device: string;
timestamp: number; source_id: string;
value: number; value: number;
type: string; // Тип метрики ("gauge", "counter", и т. д.) timestamp: number;
description?: string; // Описание метрики type?: string;
description?: string;
} }

13
src/prometheus.module.ts Normal file
View File

@ -0,0 +1,13 @@
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,6 +3,7 @@ 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 {
@ -16,7 +17,6 @@ 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,7 +24,6 @@ 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) {
@ -33,7 +32,6 @@ 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(
@ -41,7 +39,6 @@ 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) {
@ -50,8 +47,8 @@ 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 },
@ -62,23 +59,80 @@ export class PrometheusService {
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 => ({
...entry.metric, __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: entry.value[0] * 1000, timestamp: entry.value[0] * 1000,
value: parseFloat(entry.value[1]), value: parseFloat(entry.value[1]),
type: metricType || 'unknown', type: metricType || 'gauge',
description: metricDescription, // Добавляем описание 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[]> {
async fetchMetricsRange(metric: string, start: number, end: number, step: number): Promise<PrometheusMetric[]> { try {
const query = this.buildFilteredQuery(metric, filters);
const response = await lastValueFrom(
this.httpService.get(`${this.prometheusUrl}/query`, {
params: { query }
})
);
const metricType = await this.fetchMetricType(metric);
const metricDescription = await this.fetchMetricDescription(metric);
return response.data.data.result.map((entry): 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: 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( const response = await lastValueFrom(
this.httpService.get(`${this.prometheusUrl}/query_range`, { this.httpService.get(`${this.prometheusUrl}/query_range`, {
params: { params: {
query: metric, query,
start, start,
end, end,
step, step: step.toString()
}, },
}) })
); );
@ -88,16 +142,81 @@ export class PrometheusService {
return response.data.data.result.flatMap((entry) => return response.data.data.result.flatMap((entry) =>
entry.values.map((value): PrometheusMetric => ({ entry.values.map((value): PrometheusMetric => ({
...entry.metric, __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, timestamp: value[0] * 1000,
value: parseFloat(value[1]), value: parseFloat(value[1]),
type: metricType || 'unknown', type: metricType || 'gauge',
description: metricDescription, // Добавляем описание 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`)
@ -105,7 +224,6 @@ 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,6 +16,9 @@
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"noImplicitAny": false, "noImplicitAny": false,
"strictBindCallApply": false, "strictBindCallApply": false,
"noFallthroughCasesInSwitch": false "noFallthroughCasesInSwitch": false,
"types": [
"node"
]
} }
} }