Compare commits
75 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
be67f7dc07 | |
|
|
e264d59338 | |
|
|
2beb4e0ee9 | |
|
|
054b25484c | |
|
|
695644e505 | |
|
|
d060862e47 | |
|
|
61292165d1 | |
|
|
d97a0b95f6 | |
|
|
6a47ef4601 | |
|
|
05b7609914 | |
|
|
19fae6d7fe | |
|
|
aaa2482f04 | |
|
|
d8a8d6b8e5 | |
|
|
c85b89b288 | |
|
|
f3ebbc79d5 | |
|
|
0f4f4bcf15 | |
|
|
3daef48d4c | |
|
|
ad2f740384 | |
|
|
646597d111 | |
|
|
a9650366eb | |
|
|
39c7bc8fc0 | |
|
|
86f6614f56 | |
|
|
8f538c44a8 | |
|
|
926ea01235 | |
|
|
2067bb9c55 | |
|
|
8466aa1f93 | |
|
|
ff3bf02d2e | |
|
|
a76b0b9a86 | |
|
|
c3b9983b73 | |
|
|
8a5d530d44 | |
|
|
8672ca7112 | |
|
|
8abfff99e0 | |
|
|
57cf65b9a6 | |
|
|
4074d45384 | |
|
|
dadb6f3bcb | |
|
|
16ce1da9a2 | |
|
|
f1527abae6 | |
|
|
adf24d9b56 | |
|
|
066a1e4eba | |
|
|
7f6f2171a3 | |
|
|
5fc70cd610 | |
|
|
054fc46a01 | |
|
|
4c0a272df2 | |
|
|
5f1708424f | |
|
|
a5a98ef668 | |
|
|
042cc03948 | |
|
|
c3a154bfe1 | |
|
|
607e884a81 | |
|
|
5c73658c2f | |
|
|
4e1cd72f59 | |
|
|
319e2cdd69 | |
|
|
1a63f20bb0 | |
|
|
fcab8fa2b8 | |
|
|
37690dc79f | |
|
|
918656a5b4 | |
|
|
2c06038b0e | |
|
|
ce363d8b6b | |
|
|
c5e5d62f07 | |
|
|
7d8a207728 | |
|
|
296c6dddc8 | |
|
|
243a45756a | |
|
|
23d2fd7eff | |
|
|
553b9141d4 | |
|
|
23438a0e7f | |
|
|
49a5471c01 | |
|
|
7802808f62 | |
|
|
3f582917fd | |
|
|
d66279885b | |
|
|
c4fdfb1280 | |
|
|
8b494be023 | |
|
|
c02a6fdbe1 | |
|
|
b8b55951e8 | |
|
|
a57a906b0a | |
|
|
a8da6a238e | |
|
|
c83bbd4149 |
45
.env
45
.env
|
|
@ -1,9 +1,42 @@
|
|||
# Прометеус
|
||||
#PROMETHEUS_API=http://192.168.2.34:9090/api/v1
|
||||
PROMETHEUS_API=http://192.168.2.34:9090/api/v1
|
||||
PROMETHEUS_INSTANCE=192.168.2.34:9050
|
||||
|
||||
FRONTEND_URL=http://localhost:5173
|
||||
|
||||
# Постгресс
|
||||
#DB_HOST=192.168.2.37
|
||||
#DB_PORT=5432
|
||||
#DB_USER=trust
|
||||
#DB_PASSWORD=kaiqolzp2a4aH
|
||||
#DB_NAME=trust-db
|
||||
DB_HOST=192.168.2.37
|
||||
DB_PORT=5432
|
||||
DB_USER=trust
|
||||
DB_PASSWORD=kaiqolzp2a4aH
|
||||
DB_NAME=trust-db
|
||||
|
||||
|
||||
# JWT
|
||||
#JWT_SECRET=x7F!2p9L#q1$z0*8R5vYgMnBk
|
||||
JWT_SECRET=x7Fcdp9Lq1$z0*8R5vYgMnBk
|
||||
|
||||
# COOKIE
|
||||
# Для production
|
||||
#COOKIE_SECURE=true
|
||||
#COOKIE_SAME_SITE=strict
|
||||
|
||||
# Для development
|
||||
COOKIE_SECURE=false
|
||||
COOKIE_SAME_SITE=lax
|
||||
|
||||
# Для меню
|
||||
RANGES_API_URL=http://192.168.2.39:9999
|
||||
RANGES_API_ENDPOINT=/api/ranges/9999
|
||||
|
||||
FORMULA_API_URL=http://192.168.2.39:9999
|
||||
FORMULA_API_ENDPOINT=/api/integration/7777
|
||||
|
||||
# ClickHouse
|
||||
CLICKHOUSE_HOST=http://192.168.2.37:8123
|
||||
CLICKHOUSE_USER=vlad
|
||||
CLICKHOUSE_PASSWORD=vlad
|
||||
CLICKHOUSE_DB=zvks
|
||||
|
||||
# Для ai api
|
||||
AI_SERVICE_URL=http://192.168.2.39:5134
|
||||
|
|
@ -52,5 +52,12 @@ pids
|
|||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Игнорировать .env файлы
|
||||
.env
|
||||
.env.local
|
||||
.env.development
|
||||
.env.production
|
||||
.env.test
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
|
|
|||
|
|
@ -10,4 +10,6 @@ COPY . .
|
|||
|
||||
ENV NODE_ENV=development
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["npm", "run", "start:dev"]
|
||||
|
|
|
|||
|
|
@ -30,7 +30,23 @@ pipeline {
|
|||
stage ('Initialize variables') {
|
||||
steps {
|
||||
script {
|
||||
env.IMAGE_TAG = sh(script: "git describe --tags --abbrev=0", returnStdout: true).trim()
|
||||
def hasTags = sh(script: "git tag -l | wc -l", returnStdout: true).trim().toInteger() > 0
|
||||
echo "${hasTags}"
|
||||
|
||||
def lastVersion = "0.0.0"
|
||||
|
||||
if (hasTags) {
|
||||
lastVersion = sh(script: "git describe --tags --abbrev=0", returnStdout: true).trim()
|
||||
}
|
||||
|
||||
echo "Last version: ${lastVersion}"
|
||||
|
||||
def (major, minor, patch) = lastVersion.tokenize('.')
|
||||
def newVersion = "${major}.${minor}.${patch.toInteger() + 1}"
|
||||
echo "New version: ${newVersion}"
|
||||
|
||||
env.IMAGE_TAG = newVersion
|
||||
env.NEW_VERSION = newVersion
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -71,17 +87,29 @@ pipeline {
|
|||
echo "Attempting to merge PR ${env.CHANGE_ID} into master..."
|
||||
withCredentials([usernamePassword(credentialsId: 'gitea_creds', usernameVariable: 'GITEA_USER', passwordVariable: 'GITEA_PASS')]) {
|
||||
def prId = env.CHANGE_ID
|
||||
|
||||
sh """
|
||||
curl -X POST \
|
||||
-u "${GITEA_USER}:${GITEA_PASS}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"do":"merge"}' \
|
||||
http://git.entcor/api/v1/repos/deployer3000/trust-module-backend/pulls/${prId}/merge
|
||||
http://git.entcor/api/v1/repos/deployer3000/${env.IMAGE_NAME}/pulls/${prId}/merge
|
||||
"""
|
||||
def commitHash = sh(script: "git rev-parse HEAD~1", returnStdout: true).trim() // необходим для корректного отображения статусов
|
||||
echo "PR ${prId} merged successfully into main!"
|
||||
def context = "test-org/trust-module-backend/pipeline/pr-${env.CHANGE_TARGET}"
|
||||
def commitHash = sh(script: "git rev-parse HEAD~1", returnStdout: true).trim()
|
||||
notify(context, GITEA_USER, GITEA_PASS, env.GITEA_REPOSITORY_URL, "trust-module-backend", commitHash, "success")
|
||||
sleep(time: 15, unit: 'SECONDS')
|
||||
sh "git checkout main && git pull origin main"
|
||||
|
||||
sh """
|
||||
curl -v -X POST -u "${GITEA_USER}:${GITEA_PASS}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"tag_name": "${env.NEW_VERSION}", "name": "Release ${env.NEW_VERSION}", "target_commitish": "main"}' \
|
||||
"${env.GITEA_REPOSITORY_URL}deployer3000/${env.IMAGE_NAME}/releases"
|
||||
"""
|
||||
echo "New release succeeded!"
|
||||
|
||||
def context = "test-org/${env.IMAGE_NAME}/pipeline/pr-${env.CHANGE_TARGET}"
|
||||
notify(context, GITEA_USER, GITEA_PASS, env.GITEA_REPOSITORY_URL, env.IMAGE_NAME, commitHash, "success")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
31
package.json
31
package.json
|
|
@ -20,20 +20,35 @@
|
|||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clickhouse/client": "^1.11.2",
|
||||
"@clickhouse/client-web": "^1.11.2",
|
||||
"@nestjs/axios": "^4.0.0",
|
||||
"@nestjs/common": "^11.0.1",
|
||||
"@nestjs/core": "^11.0.1",
|
||||
"@nestjs/config": "^4.0.0",
|
||||
"@nestjs/core": "^11.0.1",
|
||||
"@nestjs/jwt": "^11.0.0",
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"axios": "^1.7.9",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"dotenv": "^16.3.1",
|
||||
"rxjs": "^7.8.1",
|
||||
"@nestjs/platform-socket.io": "11.0.12",
|
||||
"@nestjs/swagger": "11.1.4",
|
||||
"@nestjs/typeorm": "^11.0.0",
|
||||
"pg": "^8.14.1",
|
||||
"typeorm": "^0.3.21",
|
||||
"@nestjs/websockets": "11.0.12",
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/cookie-parser": "^1.4.8",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"axios": "^1.7.9",
|
||||
"bcrypt": "^5.1.1",
|
||||
"@types/bcrypt": "^5.0.2"
|
||||
"cookie-parser": "^1.4.7",
|
||||
"date-fns": "4.1.0",
|
||||
"dotenv": "^16.3.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"pg": "^8.14.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"socket.io": "^4.8.1",
|
||||
"typeorm": "^0.3.21",
|
||||
"ws": "^8.18.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.2.0",
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { PrometheusService } from './prometheus.service';
|
||||
import { MetricsController } from './metrics.controller';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { MenuModule } from './menu/menu.module';
|
||||
import { PrometheusModule } from './prometheus/prometheus.module';
|
||||
import { ClickHouseModule } from './clickhouse/clickhouse.module';
|
||||
import { ClickHouseController } from './clickhouse/clickhouse.controller';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
|
|
@ -25,8 +27,10 @@ import { AuthModule } from './auth/auth.module';
|
|||
}),
|
||||
HttpModule,
|
||||
AuthModule,
|
||||
PrometheusModule,
|
||||
MenuModule,
|
||||
ClickHouseModule,
|
||||
],
|
||||
controllers: [MetricsController],
|
||||
providers: [PrometheusService],
|
||||
controllers: [ClickHouseController],
|
||||
})
|
||||
export class AppModule { }
|
||||
|
|
@ -1,16 +1,118 @@
|
|||
import { Controller, Post, Body, UnauthorizedException } from '@nestjs/common';
|
||||
import { Controller, Post, Get, Body, Res, Req, UnauthorizedException, UseGuards, ForbiddenException, Delete, Param } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { Response, Request } from 'express';
|
||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { User } from './user.entity';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
private readonly logger = new Logger(AuthController.name);
|
||||
|
||||
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; role?: string };
|
||||
const userWithoutPassword = {
|
||||
id: user.userId,
|
||||
login: user.login || user.username,
|
||||
role: user.role
|
||||
};
|
||||
|
||||
this.logger.log(`Аутентифицированный пользователь: ${user.username}, роль: ${user.role}`);
|
||||
return {
|
||||
isAuthenticated: true,
|
||||
user: userWithoutPassword
|
||||
};
|
||||
}
|
||||
|
||||
@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);
|
||||
if (!user) {
|
||||
this.logger.warn(`Failed login attempt for user: ${body.login}`);
|
||||
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,
|
||||
role: user.role // Добавляем роль в ответ
|
||||
},
|
||||
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 };
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('users')
|
||||
async getAllUsers(@Req() req: Request) {
|
||||
const user = req.user as User;
|
||||
const isAdmin = await this.authService.isAdmin(user.id);
|
||||
if (!isAdmin) {
|
||||
throw new ForbiddenException('Only admin can access this resource');
|
||||
}
|
||||
return this.authService.getAllUsers();
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post('users')
|
||||
async createUser(
|
||||
@Body() body: { login: string; password: string; role?: 'user' | 'admin' },
|
||||
@Req() req: Request
|
||||
) {
|
||||
const user = req.user as User;
|
||||
const isAdmin = await this.authService.isAdmin(user.id);
|
||||
if (!isAdmin) {
|
||||
throw new ForbiddenException('Only admin can create users');
|
||||
}
|
||||
return this.authService.createUser(body.login, body.password, body.role);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Delete('users/:id')
|
||||
async deleteUser(@Param('id') id: string, @Req() req: Request) {
|
||||
const user = req.user as User;
|
||||
const isAdmin = await this.authService.isAdmin(user.id);
|
||||
if (!isAdmin) {
|
||||
throw new ForbiddenException('Only admin can delete users');
|
||||
}
|
||||
await this.authService.deleteUser(parseInt(id));
|
||||
return { message: 'User deleted successfully' };
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
*/
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 { AuthService } from './auth.service';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { User } from './user.entity';
|
||||
import { JwtStrategy } from './jwt.strategy';
|
||||
import * as cookieParser from 'cookie-parser';
|
||||
|
||||
@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],
|
||||
providers: [AuthService],
|
||||
providers: [AuthService, JwtStrategy],
|
||||
})
|
||||
export class AuthModule { }
|
||||
export class AuthModule {
|
||||
configure(consumer: MiddlewareConsumer) {
|
||||
consumer.apply(cookieParser()).forRoutes('*');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,31 +1,71 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, ForbiddenException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { User } from './user.entity';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
@InjectRepository(User)
|
||||
private usersRepository: Repository<User>,
|
||||
private jwtService: JwtService,
|
||||
) { }
|
||||
|
||||
async validateUser(login: string, password: string): Promise<any> {
|
||||
console.log(`Проверка пользователя: ${login}, пароль: ${password}`);
|
||||
|
||||
// Ищем пользователя по login
|
||||
async validateUser(login: string, password: string): Promise<any> {
|
||||
const user = await this.usersRepository.findOne({ where: { login } });
|
||||
|
||||
console.log(`Найденный пользователь:`, user);
|
||||
|
||||
// Проверяем, что нашли пользователя и пароль совпадает
|
||||
if (user && user.password === password) {
|
||||
console.log(`Авторизация успешна`);
|
||||
const { password, ...result } = user;
|
||||
return result;
|
||||
return {
|
||||
...result,
|
||||
role: user.role
|
||||
};
|
||||
}
|
||||
|
||||
console.log(`Ошибка: неверный логин или пароль`);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
|
||||
async login(user: any) {
|
||||
const payload = {
|
||||
username: user.login,
|
||||
sub: user.id,
|
||||
role: user.role
|
||||
};
|
||||
return {
|
||||
access_token: this.jwtService.sign(payload),
|
||||
};
|
||||
}
|
||||
|
||||
async getAllUsers(): Promise<User[]> {
|
||||
return this.usersRepository.find();
|
||||
}
|
||||
|
||||
async createUser(login: string, password: string, role: 'user' | 'admin' = 'user'): Promise<User> {
|
||||
// const hashedPassword = await bcrypt.hash(password, 10);
|
||||
const user = this.usersRepository.create({
|
||||
login,
|
||||
password, //hashedPassword,
|
||||
role
|
||||
});
|
||||
return this.usersRepository.save(user);
|
||||
}
|
||||
|
||||
async deleteUser(id: number): Promise<void> {
|
||||
const user = await this.usersRepository.findOne({ where: { id } });
|
||||
if (user && user.role === 'admin') {
|
||||
throw new ForbiddenException('Cannot delete admin user');
|
||||
}
|
||||
await this.usersRepository.delete(id);
|
||||
}
|
||||
|
||||
async isAdmin(userId: number): Promise<boolean> {
|
||||
const user = await this.usersRepository.findOne({ where: { id: userId } });
|
||||
return user?.role === 'admin';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
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,
|
||||
role: payload.role
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -10,4 +10,7 @@ export class User {
|
|||
|
||||
@Column()
|
||||
password: string;
|
||||
|
||||
@Column({ default: 'user' })
|
||||
role: 'user' | 'admin';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,62 @@
|
|||
import { Controller, Get, Post } from '@nestjs/common';
|
||||
import { ClickHouseService } from './clickhouse.service';
|
||||
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { HttpService } from '@nestjs/axios';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
@ApiTags('Clickhouse')
|
||||
@Controller('clickhouse')
|
||||
export class ClickHouseController {
|
||||
constructor(
|
||||
private readonly clickhouseService: ClickHouseService,
|
||||
private readonly httpService: HttpService,
|
||||
private readonly configService: ConfigService,
|
||||
) { }
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Get metrics from ClickHouse' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Metrics data',
|
||||
schema: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
description: { type: 'string' },
|
||||
device: { type: 'number' },
|
||||
id: { type: 'string' },
|
||||
name: { type: 'string' },
|
||||
source: { type: 'string' },
|
||||
status: { type: 'number' },
|
||||
timestamp: { type: 'number' },
|
||||
value: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
async getClckhouse() {
|
||||
return this.clickhouseService.getClckhouse();
|
||||
}
|
||||
|
||||
@Post('send-to-ai')
|
||||
@ApiOperation({ summary: 'Send metrics to AI service' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'AI service response',
|
||||
})
|
||||
async sendToAI() {
|
||||
const metrics = await this.clickhouseService.getClckhouse();
|
||||
const aiServiceUrl = this.configService.get('AI_SERVICE_URL/api/metrics/rest');
|
||||
|
||||
try {
|
||||
const response = await firstValueFrom(
|
||||
this.httpService.post(aiServiceUrl, metrics)
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to send data to AI: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import { Module, Global } from '@nestjs/common';
|
||||
import { createClient, ClickHouseClient } from '@clickhouse/client';
|
||||
import { ClickHouseService } from './clickhouse.service';
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [HttpModule],
|
||||
providers: [
|
||||
{
|
||||
provide: 'CLICKHOUSE_CLIENT',
|
||||
useFactory: (): ClickHouseClient => {
|
||||
return createClient({
|
||||
host: process.env.CLICKHOUSE_HOST || 'http://localhost:8123',
|
||||
username: process.env.CLICKHOUSE_USER || 'default',
|
||||
password: process.env.CLICKHOUSE_PASSWORD || '',
|
||||
database: process.env.CLICKHOUSE_DB || 'default',
|
||||
});
|
||||
},
|
||||
},
|
||||
ClickHouseService,
|
||||
],
|
||||
exports: ['CLICKHOUSE_CLIENT', ClickHouseService],
|
||||
})
|
||||
export class ClickHouseModule { }
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
import { Injectable, Inject } from '@nestjs/common';
|
||||
import { ClickHouseClient } from '@clickhouse/client';
|
||||
|
||||
interface ClickHouseRow {
|
||||
EventDataTime: string;
|
||||
ParameterBody: string;
|
||||
CreateDataTime: string;
|
||||
}
|
||||
|
||||
interface MetricData {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
addr?: string;
|
||||
value: number | string | null;
|
||||
description: string;
|
||||
status: number;
|
||||
device: number;
|
||||
source: string;
|
||||
}
|
||||
|
||||
interface ParameterBody {
|
||||
service_name: string;
|
||||
metrics: MetricData[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ClickHouseService {
|
||||
constructor(
|
||||
@Inject('CLICKHOUSE_CLIENT')
|
||||
private readonly clickhouseClient: ClickHouseClient,
|
||||
) { }
|
||||
|
||||
async getClckhouse() {
|
||||
const query = `
|
||||
SELECT
|
||||
EventDataTime,
|
||||
ParameterBody,
|
||||
CreateDataTime
|
||||
FROM zvks.complex_parameters
|
||||
ORDER BY EventDataTime DESC
|
||||
LIMIT 100
|
||||
`;
|
||||
|
||||
const result = await this.clickhouseClient.query({
|
||||
query,
|
||||
format: 'JSONEachRow',
|
||||
});
|
||||
|
||||
const rows = await result.json<ClickHouseRow>();
|
||||
|
||||
// Парсинг данных
|
||||
return rows.flatMap((row: ClickHouseRow) => {
|
||||
try {
|
||||
const parameterBody: ParameterBody = JSON.parse(row.ParameterBody);
|
||||
return parameterBody.metrics.map((metric: MetricData) => ({
|
||||
id: metric.id,
|
||||
name: metric.name,
|
||||
value: metric.value !== null ? metric.value.toString() : 'null',
|
||||
description: metric.description,
|
||||
status: metric.status,
|
||||
device: metric.device,
|
||||
source: metric.source,
|
||||
timestamp: new Date(row.EventDataTime).getTime(),
|
||||
}));
|
||||
} catch (e) {
|
||||
console.error('Error parsing metric:', e);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
56
src/main.ts
56
src/main.ts
|
|
@ -1,15 +1,59 @@
|
|||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
const logger = new Logger('Bootstrap');
|
||||
|
||||
app.enableCors({
|
||||
origin: '*',
|
||||
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE',
|
||||
allowedHeaders: 'Content-Type, Authorization',
|
||||
// Установка глобального префикса для всех маршрутов
|
||||
app.setGlobalPrefix('api');
|
||||
|
||||
// Настройка 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();
|
||||
|
||||
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',
|
||||
});
|
||||
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();
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
export interface MetricMetadata {
|
||||
metric: string;
|
||||
type: string;
|
||||
help: string;
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
// controllers/enriched-formula.controller.ts
|
||||
import { Controller, Get, Param } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiParam } from '@nestjs/swagger';
|
||||
import { FormulaEnrichmentService } from './formula-enrichment.service';
|
||||
import { EnrichedFormulaMetric } from './formula.interface';
|
||||
|
||||
@ApiTags('Enriched Formulas')
|
||||
@Controller('enriched-formulas')
|
||||
export class EnrichedFormulaController {
|
||||
constructor(
|
||||
private readonly formulaEnrichmentService: FormulaEnrichmentService
|
||||
) { }
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Получить все формулы с обогащенными данными' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Список формул с обогащенными метриками'
|
||||
})
|
||||
async getAllEnrichedFormulas(): Promise<EnrichedFormulaMetric[]> {
|
||||
return this.formulaEnrichmentService.getAllEnrichedFormulas();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Получить конкретную формулу с обогащенными данными' })
|
||||
@ApiParam({ name: 'id', description: 'ID формулы' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Формула с обогащенными метриками'
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 404,
|
||||
description: 'Формула не найдена'
|
||||
})
|
||||
async getEnrichedFormula(@Param('id') id: string): Promise<EnrichedFormulaMetric> {
|
||||
return this.formulaEnrichmentService.getEnrichedFormula(id);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,307 @@
|
|||
// services/formula-enrichment.service.ts
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PrometheusService } from '../prometheus/prometheus.service';
|
||||
import { FormulaService } from './formula.service';
|
||||
import {
|
||||
FormulaMetric,
|
||||
EnrichedFormulaMetric,
|
||||
EnrichedMetric
|
||||
} from './formula.interface';
|
||||
|
||||
@Injectable()
|
||||
export class FormulaEnrichmentService {
|
||||
private readonly logger = new Logger(FormulaEnrichmentService.name);
|
||||
|
||||
// Конфигурируемые префиксы
|
||||
private readonly metricPrefixes = ['zvks_', 'server_', 'application_'];
|
||||
private readonly defaultPrefix = 'zvks_';
|
||||
|
||||
constructor(
|
||||
private readonly prometheusService: PrometheusService,
|
||||
private readonly formulaService: FormulaService
|
||||
) { }
|
||||
|
||||
/**
|
||||
* Автоматически определяет имя метрики в Prometheus
|
||||
*/
|
||||
private resolveMetricName(originalName: string): string {
|
||||
// Если имя уже содержит префикс, используем как есть
|
||||
if (this.metricPrefixes.some(prefix => originalName.startsWith(prefix))) {
|
||||
return originalName;
|
||||
}
|
||||
|
||||
// Иначе добавляем дефолтный префикс
|
||||
return `${this.defaultPrefix}${originalName}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ищет метрику в Prometheus с учетом различных вариантов имен
|
||||
*/
|
||||
private async findMetricInPrometheus(originalName: string): Promise<{
|
||||
prometheusName: string;
|
||||
metrics: any[];
|
||||
found: boolean;
|
||||
}> {
|
||||
const possibleNames = [
|
||||
originalName, // как есть
|
||||
this.resolveMetricName(originalName), // с дефолтным префиксом
|
||||
// Можно добавить другие варианты если нужно
|
||||
];
|
||||
|
||||
for (const metricName of possibleNames) {
|
||||
try {
|
||||
this.logger.debug(`Trying to find metric: ${metricName}`);
|
||||
const metrics = await this.prometheusService.fetchMetrics(metricName);
|
||||
|
||||
if (metrics && metrics.length > 0) {
|
||||
this.logger.debug(`Found metric: ${metricName} with ${metrics.length} entries`);
|
||||
return {
|
||||
prometheusName: metricName,
|
||||
metrics,
|
||||
found: true
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.debug(`Metric ${metricName} not found: ${error.message}`);
|
||||
// Продолжаем поиск с следующим вариантом
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
prometheusName: this.resolveMetricName(originalName),
|
||||
metrics: [],
|
||||
found: false
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить обогащенные данные формулы по ID
|
||||
*/
|
||||
async getEnrichedFormula(id: string): Promise<EnrichedFormulaMetric> {
|
||||
const formulaData = await this.formulaService.getFormulaData(id);
|
||||
|
||||
if (!formulaData || !formulaData.values) {
|
||||
throw new Error(`Formula data not found for id: ${id}`);
|
||||
}
|
||||
|
||||
const enrichedMetrics = await this.enrichMetrics(formulaData.values.statusarr);
|
||||
const { parsedFormula, humanReadableFormula } = this.parseFormula(
|
||||
formulaData.formula,
|
||||
enrichedMetrics
|
||||
);
|
||||
|
||||
return {
|
||||
...formulaData,
|
||||
enrichedMetrics,
|
||||
parsedFormula,
|
||||
humanReadableFormula,
|
||||
metadata: {
|
||||
totalMetrics: enrichedMetrics.length,
|
||||
foundMetrics: enrichedMetrics.filter(m => m.found).length,
|
||||
missingMetrics: enrichedMetrics.filter(m => !m.found).length
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Обогащает массив метрик данными из Prometheus
|
||||
*/
|
||||
private async enrichMetrics(metricNames: string[]): Promise<EnrichedMetric[]> {
|
||||
const enrichmentPromises = metricNames.map(async (originalName) => {
|
||||
try {
|
||||
const { prometheusName, metrics, found } = await this.findMetricInPrometheus(originalName);
|
||||
|
||||
if (found && metrics.length > 0) {
|
||||
const metric = metrics[0]; // Берем первую метрику как пример
|
||||
const description = metric.description || await this.getMetricDescription(prometheusName);
|
||||
|
||||
return {
|
||||
originalName,
|
||||
prometheusName,
|
||||
description,
|
||||
currentValue: metric.value,
|
||||
device: metric.device,
|
||||
source_id: metric.source_id,
|
||||
timestamp: metric.timestamp,
|
||||
type: metric.type,
|
||||
found: true,
|
||||
valuesCount: metrics.length
|
||||
};
|
||||
} else {
|
||||
// Метрика не найдена
|
||||
const description = await this.getMetricDescription(prometheusName);
|
||||
return {
|
||||
originalName,
|
||||
prometheusName,
|
||||
description: description || 'Метрика не найдена в Prometheus',
|
||||
currentValue: undefined,
|
||||
found: false,
|
||||
valuesCount: 0,
|
||||
error: `Метрика не найдена. Проверенные имена: ${prometheusName}`
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`Error enriching metric ${originalName}:`, error);
|
||||
return {
|
||||
originalName,
|
||||
prometheusName: this.resolveMetricName(originalName),
|
||||
description: `Ошибка при получении данных: ${error.message}`,
|
||||
currentValue: undefined,
|
||||
found: false,
|
||||
valuesCount: 0,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return Promise.all(enrichmentPromises);
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает описание метрики
|
||||
*/
|
||||
private async getMetricDescription(metricName: string): Promise<string> {
|
||||
try {
|
||||
const description = await this.prometheusService.fetchMetricDescription(metricName);
|
||||
return description || 'Описание недоступно';
|
||||
} catch (error) {
|
||||
return 'Описание недоступно';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Парсит формулу для лучшего отображения
|
||||
*/
|
||||
private parseFormula(
|
||||
formula: string,
|
||||
enrichedMetrics: EnrichedMetric[]
|
||||
): { parsedFormula: string; humanReadableFormula: string } {
|
||||
let humanReadableFormula = formula;
|
||||
|
||||
// Заменяем statusarr[index] на описания метрик
|
||||
enrichedMetrics.forEach((metric, index) => {
|
||||
const arrayIndex = index + 1; // В формулах индексы с 1
|
||||
const statusarrPattern = new RegExp(`statusarr\\[${arrayIndex}\\]`, 'g');
|
||||
|
||||
// Для humanReadableFormula используем описания метрик
|
||||
if (metric.found) {
|
||||
humanReadableFormula = humanReadableFormula.replace(
|
||||
statusarrPattern,
|
||||
metric.description
|
||||
);
|
||||
} else {
|
||||
humanReadableFormula = humanReadableFormula.replace(
|
||||
statusarrPattern,
|
||||
`${metric.originalName} (НЕ НАЙДЕНА)`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Форматируем для лучшей читаемости
|
||||
humanReadableFormula = humanReadableFormula
|
||||
.replace(/\*/g, ' × ')
|
||||
.replace(/\//g, ' ÷ ')
|
||||
.replace(/\+/g, ' + ')
|
||||
.replace(/-/g, ' - ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
return {
|
||||
parsedFormula: formula,
|
||||
humanReadableFormula
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить все доступные формулы с обогащенными данными
|
||||
*/
|
||||
async getAllEnrichedFormulas(): Promise<EnrichedFormulaMetric[]> {
|
||||
try {
|
||||
const formulaOptions = await this.formulaService.getFormulaOptions('');
|
||||
|
||||
if (!Array.isArray(formulaOptions)) {
|
||||
throw new Error('Invalid formula options response');
|
||||
}
|
||||
|
||||
const enrichmentPromises = formulaOptions.map(async (formulaOption) => {
|
||||
try {
|
||||
return await this.getEnrichedFormula(formulaOption.id);
|
||||
} catch (error) {
|
||||
this.logger.error(`Error enriching formula ${formulaOption.id}:`, error);
|
||||
return {
|
||||
...formulaOption,
|
||||
enrichedMetrics: [],
|
||||
parsedFormula: formulaOption.formula || '',
|
||||
humanReadableFormula: formulaOption.formula || '',
|
||||
metadata: {
|
||||
totalMetrics: 0,
|
||||
foundMetrics: 0,
|
||||
missingMetrics: 0
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return Promise.all(enrichmentPromises);
|
||||
} catch (error) {
|
||||
this.logger.error('Error getting all enriched formulas:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Диагностика - проверка доступности метрик
|
||||
*/
|
||||
async diagnoseMetrics(metricNames: string[]): Promise<any> {
|
||||
const results = await Promise.all(
|
||||
metricNames.map(async (originalName) => {
|
||||
const { prometheusName, metrics, found } = await this.findMetricInPrometheus(originalName);
|
||||
|
||||
return {
|
||||
originalName,
|
||||
prometheusName,
|
||||
found,
|
||||
availableNames: await this.findAvailableMetricNames(originalName),
|
||||
metricsCount: metrics.length,
|
||||
sampleValue: found && metrics[0] ? metrics[0].value : null
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
diagnosis: results,
|
||||
summary: {
|
||||
total: results.length,
|
||||
found: results.filter(r => r.found).length,
|
||||
notFound: results.filter(r => !r.found).length
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Поиск доступных вариантов имен метрик
|
||||
*/
|
||||
private async findAvailableMetricNames(baseName: string): Promise<string[]> {
|
||||
const possibleNames = [
|
||||
baseName,
|
||||
`${this.defaultPrefix}${baseName}`,
|
||||
...this.metricPrefixes.map(prefix => `${prefix}${baseName}`)
|
||||
];
|
||||
|
||||
const availableNames: string[] = [];
|
||||
|
||||
for (const name of possibleNames) {
|
||||
try {
|
||||
const metrics = await this.prometheusService.fetchMetrics(name);
|
||||
if (metrics && metrics.length > 0) {
|
||||
availableNames.push(name);
|
||||
}
|
||||
} catch (error) {
|
||||
// Игнорируем ошибки - метрика не найдена
|
||||
}
|
||||
}
|
||||
|
||||
return availableNames;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
import { Controller, Get, Post, Body, HttpException, HttpStatus, Param } from '@nestjs/common';
|
||||
import { FormulaService } from './formula.service';
|
||||
import { MenuService } from './menu.service';
|
||||
import { FormulaEnrichmentService } from './formula-enrichment.service';
|
||||
|
||||
@Controller('formula')
|
||||
export class FormulaController {
|
||||
constructor(
|
||||
private readonly FormulaService: FormulaService,
|
||||
private readonly menuService: MenuService,
|
||||
private readonly formulaEnrichmentService: FormulaEnrichmentService
|
||||
) { }
|
||||
|
||||
@Get(':id')
|
||||
async getFormulaData(@Param('id') id: string) {
|
||||
try {
|
||||
return await this.FormulaService.getFormulaData(id);
|
||||
} catch (error) {
|
||||
throw new HttpException('Failed to fetch Formula data', HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
@Post(':id/update')
|
||||
async updateFormulaData(
|
||||
@Param('id') id: string,
|
||||
@Body() data: any
|
||||
) {
|
||||
if (!data) {
|
||||
throw new HttpException('Invalid data format', HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.FormulaService.updateFormulaData(id, data);
|
||||
this.menuService.invalidateCache();
|
||||
return result;
|
||||
} catch (error) {
|
||||
throw new HttpException(error.message, HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
// OPTIONS метод для получения данных (как в вашем примере curl)
|
||||
@Get(':id/options')
|
||||
async getFormulaOptions(@Param('id') id: string) {
|
||||
try {
|
||||
return await this.FormulaService.getFormulaOptions(id);
|
||||
} catch (error) {
|
||||
throw new HttpException('Failed to fetch Formula options', HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
@Get(':id/enriched')
|
||||
async getEnrichedFormulaData(@Param('id') id: string) {
|
||||
try {
|
||||
return await this.formulaEnrichmentService.getEnrichedFormula(id);
|
||||
} catch (error) {
|
||||
throw new HttpException(
|
||||
'Failed to fetch enriched formula data',
|
||||
HttpStatus.INTERNAL_SERVER_ERROR
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Get('all/enriched')
|
||||
async getAllEnrichedFormulas() {
|
||||
try {
|
||||
return await this.formulaEnrichmentService.getAllEnrichedFormulas();
|
||||
} catch (error) {
|
||||
throw new HttpException(
|
||||
'Failed to fetch enriched formulas',
|
||||
HttpStatus.INTERNAL_SERVER_ERROR
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
// interfaces/formula.interface.ts
|
||||
export interface FormulaMetric {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
values: {
|
||||
statusarr: string[];
|
||||
warr: string[];
|
||||
};
|
||||
formula: string;
|
||||
}
|
||||
|
||||
export interface EnrichedFormulaMetric extends FormulaMetric {
|
||||
enrichedMetrics: EnrichedMetric[];
|
||||
parsedFormula: string;
|
||||
humanReadableFormula: string;
|
||||
metadata: {
|
||||
totalMetrics: number;
|
||||
foundMetrics: number;
|
||||
missingMetrics: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface EnrichedMetric {
|
||||
originalName: string;
|
||||
prometheusName: string;
|
||||
description: string;
|
||||
currentValue?: number;
|
||||
device?: string;
|
||||
source_id?: string;
|
||||
timestamp?: number;
|
||||
type?: string;
|
||||
found: boolean;
|
||||
valuesCount?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { HttpService } from '@nestjs/axios';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
@Injectable()
|
||||
export class FormulaService {
|
||||
private readonly FormulaApiUrl: string;
|
||||
private readonly FormulaApiEndpoint: string;
|
||||
|
||||
constructor(
|
||||
private readonly httpService: HttpService,
|
||||
private readonly configService: ConfigService
|
||||
) {
|
||||
this.FormulaApiUrl = this.configService.get<string>('FORMULA_API_URL', 'http://192.168.2.39:9999');
|
||||
this.FormulaApiEndpoint = this.configService.get<string>('FORMULA_API_ENDPOINT', '/api/integration/7777');
|
||||
}
|
||||
|
||||
async getFormulaData(id: string): Promise<any> {
|
||||
try {
|
||||
const response = await firstValueFrom(
|
||||
this.httpService.get(`${this.FormulaApiUrl}${this.FormulaApiEndpoint}/${id}`, {
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch Formula data:', error);
|
||||
this.handleError(error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async getFormulaOptions(id: string): Promise<any> {
|
||||
try {
|
||||
const url = `${this.FormulaApiUrl}${this.FormulaApiEndpoint}`;
|
||||
console.log('Fetching Formula options via OPTIONS:', url);
|
||||
|
||||
const response = await firstValueFrom(
|
||||
this.httpService.request({
|
||||
method: 'OPTIONS',
|
||||
url,
|
||||
headers: { 'Accept': 'application/json' }
|
||||
})
|
||||
);
|
||||
|
||||
console.log('Response from Formula API:', response.data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch Formula options:', error);
|
||||
this.handleError(error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async updateFormulaData(id: string, data: any) {
|
||||
try {
|
||||
const response = await firstValueFrom(
|
||||
this.httpService.post(
|
||||
`${this.FormulaApiUrl}${this.FormulaApiEndpoint}/${id}`,
|
||||
data,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Failed to update Formula data:', error);
|
||||
this.handleError(error);
|
||||
throw new Error('Failed to update Formula data');
|
||||
}
|
||||
}
|
||||
|
||||
private handleError(error: any): void {
|
||||
if (error.response) {
|
||||
console.error('Server responded with:', {
|
||||
status: error.response.status,
|
||||
data: error.response.data
|
||||
});
|
||||
} else if (error.request) {
|
||||
console.error('No response received:', error.request);
|
||||
} else {
|
||||
console.error('Request setup error:', error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
import { Controller, Get, Post, Put, Body, Param, Headers, HttpException, HttpStatus, Delete } from '@nestjs/common';
|
||||
import { MenuService } from './menu.service';
|
||||
import { MenuItem } from './menu.interface';
|
||||
|
||||
@Controller('menu')
|
||||
export class MenuController {
|
||||
constructor(private readonly menuService: MenuService) { }
|
||||
|
||||
@Get('full')
|
||||
async getFullMenu(@Headers('if-modified-since') ifModifiedSince?: string) {
|
||||
console.log('GET /menu/full requested');
|
||||
try {
|
||||
const result = await this.menuService.getFullMenuWithCache(ifModifiedSince);
|
||||
|
||||
if (!result.fresh && ifModifiedSince) {
|
||||
throw new HttpException('Not Modified', HttpStatus.NOT_MODIFIED);
|
||||
}
|
||||
|
||||
return result.menu;
|
||||
} catch (error) {
|
||||
if (error.status === HttpStatus.NOT_MODIFIED) {
|
||||
throw error;
|
||||
}
|
||||
throw new HttpException(
|
||||
error.message || 'Failed to load menu',
|
||||
HttpStatus.INTERNAL_SERVER_ERROR
|
||||
);
|
||||
}
|
||||
}
|
||||
/*
|
||||
@Get('full')
|
||||
async getFullMenu() {
|
||||
console.log('Simplified endpoint called');
|
||||
return { test: 'OK' }; // Простейший ответ
|
||||
} */
|
||||
|
||||
@Get('check-updates')
|
||||
async checkUpdates(@Headers('if-modified-since') ifModifiedSince: string) {
|
||||
if (!ifModifiedSince) {
|
||||
throw new HttpException('If-Modified-Since header is required', HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
const hasUpdates = await this.menuService.checkForUpdates(ifModifiedSince);
|
||||
return { hasUpdates };
|
||||
}
|
||||
|
||||
// @Post('save')
|
||||
// async saveMenu() {
|
||||
// await this.menuService.saveMenuToFile();
|
||||
// return { status: 'saved' };
|
||||
// }
|
||||
|
||||
// @Post('overrides')
|
||||
// async saveOverrides(@Body() data: { overrides: Partial<MenuItem>[] }) {
|
||||
// await this.menuService.saveOverrides(data.overrides);
|
||||
// return { status: 'success' };
|
||||
// }
|
||||
|
||||
@Put(':id')
|
||||
async updateMenuItem(
|
||||
@Param('id') id: string,
|
||||
@Body() update: Partial<MenuItem>
|
||||
) {
|
||||
try {
|
||||
const updatedItem = await this.menuService.updateMenuItem(id, update);
|
||||
return updatedItem;
|
||||
} catch (error) {
|
||||
throw new HttpException(
|
||||
error.message || 'Failed to update menu item',
|
||||
HttpStatus.INTERNAL_SERVER_ERROR
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Delete('items/:id')
|
||||
async deleteMenuItem(@Param('id') id: string) {
|
||||
console.log(`DELETE /menu/items/${id} requested`);
|
||||
try {
|
||||
await this.menuService.hideMenuItem(id);
|
||||
console.log(`Item ${id} hidden successfully`);
|
||||
return { success: true, message: 'Item hidden successfully' };
|
||||
} catch (error) {
|
||||
console.error(`Error hiding item ${id}:`, error);
|
||||
throw new HttpException(
|
||||
error.message || 'Failed to hide menu item',
|
||||
HttpStatus.INTERNAL_SERVER_ERROR
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Post('invalidate-cache')
|
||||
async invalidateCache() {
|
||||
try {
|
||||
this.menuService.invalidateCache();
|
||||
return { success: true, message: 'Cache invalidated successfully' };
|
||||
} catch (error) {
|
||||
throw new HttpException(
|
||||
error.message || 'Failed to invalidate cache',
|
||||
HttpStatus.INTERNAL_SERVER_ERROR
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Get('debug/overrides')
|
||||
async debugOverrides() {
|
||||
try {
|
||||
return { status: 'Debug endpoint not implemented' };
|
||||
} catch (error) {
|
||||
throw new HttpException(
|
||||
error.message || 'Debug failed',
|
||||
HttpStatus.INTERNAL_SERVER_ERROR
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
export interface MenuItem {
|
||||
title: string;
|
||||
id: string;
|
||||
items?: MenuItem[];
|
||||
metric?: string;
|
||||
filters?: Record<string, string>;
|
||||
isDynamic?: boolean;
|
||||
templateId?: string;
|
||||
ranges?: Array<{
|
||||
min: number;
|
||||
max: number;
|
||||
status: number;
|
||||
}>;
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { MenuController } from './menu.controller';
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { MenuService } from './menu.service';
|
||||
import { PrometheusModule } from '../prometheus/prometheus.module';
|
||||
import { RangeService } from './range.service';
|
||||
import { RangeController } from './range.controller';
|
||||
import { FormulaController } from './formula.controller';
|
||||
import { FormulaService } from './formula.service';
|
||||
import { FormulaEnrichmentService } from './formula-enrichment.service';
|
||||
import { EnrichedFormulaController } from './enriched-formula.controller';
|
||||
|
||||
@Module({
|
||||
imports: [PrometheusModule, HttpModule],
|
||||
controllers: [MenuController, RangeController, FormulaController, EnrichedFormulaController],
|
||||
providers: [MenuService, RangeService, FormulaService, FormulaEnrichmentService],
|
||||
exports: [FormulaEnrichmentService]
|
||||
})
|
||||
export class MenuModule { }
|
||||
|
|
@ -0,0 +1,517 @@
|
|||
import { Injectable, HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { PrometheusService } from '../prometheus/prometheus.service';
|
||||
import { MenuItem } from './menu.interface';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import { RangeService } from './range.service';
|
||||
|
||||
@Injectable()
|
||||
export class MenuService {
|
||||
private menuCache: MenuItem | null = null;
|
||||
private lastModified: Date | null = null;
|
||||
private cacheInitialized = false;
|
||||
private userOverrides: Map<string, Partial<MenuItem>> = new Map();
|
||||
|
||||
constructor(
|
||||
private readonly prometheusService: PrometheusService,
|
||||
private readonly rangeService: RangeService
|
||||
) { }
|
||||
|
||||
|
||||
private readonly userOverridesPath = path.join(process.cwd(), 'data', 'user_menu_overrides.json');
|
||||
|
||||
|
||||
async getFullMenuWithCache(ifModifiedSince?: string): Promise<{ menu: MenuItem; fresh: boolean }> {
|
||||
if (this.menuCache && this.lastModified && (!ifModifiedSince || new Date(ifModifiedSince) >= this.lastModified)) {
|
||||
return { menu: this.menuCache, fresh: false };
|
||||
}
|
||||
|
||||
await this.loadUserOverrides();
|
||||
|
||||
const dynamicItemsPromise = this.generateDynamicItems();
|
||||
const baseMenu = await this.injectDynamicItems(this.getStaticStructure(), dynamicItemsPromise);
|
||||
|
||||
const freshMenu = this.applyUserOverrides(baseMenu);
|
||||
|
||||
this.menuCache = freshMenu;
|
||||
this.lastModified = new Date();
|
||||
this.cacheInitialized = true;
|
||||
|
||||
return { menu: freshMenu, fresh: true };
|
||||
}
|
||||
|
||||
async checkForUpdates(ifModifiedSince: string): Promise<boolean> {
|
||||
if (!this.cacheInitialized) {
|
||||
await this.getFullMenuWithCache();
|
||||
}
|
||||
return !this.lastModified || new Date(ifModifiedSince) < this.lastModified;
|
||||
}
|
||||
|
||||
async hideMenuItem(id: string): Promise<void> {
|
||||
this.userOverrides.set(id, { id, hidden: true });
|
||||
await this.saveUserOverrides();
|
||||
this.invalidateCache();
|
||||
}
|
||||
|
||||
private applyUserOverrides(menu: MenuItem): MenuItem {
|
||||
const apply = (item: MenuItem): MenuItem | null => {
|
||||
const override = this.userOverrides.get(item.id);
|
||||
|
||||
if (override?.hidden) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const updated: MenuItem = {
|
||||
...item,
|
||||
...override,
|
||||
hidden: undefined
|
||||
};
|
||||
|
||||
if (updated.items) {
|
||||
const processedItems = updated.items
|
||||
.map(apply)
|
||||
.filter((item): item is MenuItem => item !== null);
|
||||
|
||||
updated.items = processedItems.length > 0 ? processedItems : undefined;
|
||||
}
|
||||
|
||||
return updated;
|
||||
};
|
||||
|
||||
const result = apply(menu);
|
||||
return result || { title: menu.title, id: menu.id, items: [] };
|
||||
}
|
||||
|
||||
private async loadUserOverrides(): Promise<void> {
|
||||
try {
|
||||
const content = await fs.readFile(this.userOverridesPath, 'utf-8');
|
||||
const parsed = JSON.parse(content);
|
||||
|
||||
this.userOverrides = new Map(
|
||||
(parsed.overrides || []).map(o => [o.id, o])
|
||||
);
|
||||
} catch (e) {
|
||||
this.userOverrides = new Map();
|
||||
await this.saveUserOverrides(); // Создаем файл с пустыми данными
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private async saveUserOverrides(): Promise<void> {
|
||||
try {
|
||||
await fs.mkdir(path.dirname(this.userOverridesPath), { recursive: true });
|
||||
const overridesArray = Array.from(this.userOverrides.values());
|
||||
await fs.writeFile(
|
||||
this.userOverridesPath,
|
||||
JSON.stringify({ overrides: overridesArray }, null, 2),
|
||||
'utf-8'
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error saving user overrides:', error);
|
||||
throw new HttpException(
|
||||
'Failed to save user preferences',
|
||||
HttpStatus.INTERNAL_SERVER_ERROR
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private getStaticStructure(): MenuItem {
|
||||
return {
|
||||
title: "ЗВКС",
|
||||
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>();
|
||||
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');
|
||||
const deviceItems = await Promise.all(
|
||||
devices.map(device => this.createDeviceItem(device, allSeries, metadataMap))
|
||||
);
|
||||
|
||||
return deviceItems;
|
||||
}
|
||||
|
||||
private async createDeviceItem(
|
||||
device: string,
|
||||
seriesData: { metric: string; labels: Record<string, string> }[],
|
||||
metadataMap: Map<string, string>
|
||||
): Promise<MenuItem> {
|
||||
const moduleItems = await this.generateModuleItems(device, seriesData, metadataMap);
|
||||
|
||||
const deviceName = metadataMap.get(device) ?? device;
|
||||
|
||||
return {
|
||||
id: `device_${device}`,
|
||||
title: deviceName,
|
||||
items: moduleItems,
|
||||
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 normalizeIdPart(part: string): string {
|
||||
return part
|
||||
.replace(/\$/g, '_')
|
||||
.replace(/,/g, '_')
|
||||
.replace(/\s+/g, '_')
|
||||
.replace(/[^a-zA-Z0-9-_]/g, '')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
|
||||
// private async generateModuleItems(
|
||||
// device: string,
|
||||
// seriesData: { metric: string; labels: Record<string, string> }[],
|
||||
// metadataMap: Map<string, string>
|
||||
// ): Promise<MenuItem[]> {
|
||||
// const modules = new Map<string, string>();
|
||||
|
||||
// seriesData.forEach(({ labels }) => {
|
||||
// if (labels.device === device && labels.source_id) {
|
||||
// const sourceId = labels.source_id;
|
||||
// let displayName = sourceId;
|
||||
|
||||
// if (sourceId.startsWith('module$')) {
|
||||
// displayName = `Module ${sourceId.split('$')[1]}`;
|
||||
// } else if (sourceId.startsWith('port$')) {
|
||||
// displayName = `Port ${sourceId.split('$')[1]}`;
|
||||
// }
|
||||
|
||||
// modules.set(sourceId, displayName);
|
||||
// }
|
||||
// });
|
||||
|
||||
// const modulePromises = Array.from(modules.entries()).map(
|
||||
// async ([sourceId, displayName]) => ({
|
||||
// id: `module_${device}_${sourceId}`,
|
||||
// title: displayName,
|
||||
// items: await this.generateMetricItems(device, sourceId, seriesData, metadataMap),
|
||||
// isDynamic: true
|
||||
// })
|
||||
// );
|
||||
|
||||
// return Promise.all(modulePromises);
|
||||
// }
|
||||
|
||||
// private async generateMetricItems(
|
||||
// device: string,
|
||||
// module: string,
|
||||
// seriesData: { metric: string; labels: Record<string, string> }[],
|
||||
// metadataMap: Map<string, string>
|
||||
// ): Promise<MenuItem[]> {
|
||||
// const ranges = await this.rangeService.getRanges();
|
||||
// const filtered = seriesData.filter(
|
||||
// ({ labels }) => labels.device === device && labels.source_id === module
|
||||
// );
|
||||
|
||||
// const uniqueMetrics = new Set(filtered.map(entry => entry.metric));
|
||||
// const safeDevice = this.normalizeIdPart(device);
|
||||
// const safeModule = this.normalizeIdPart(module);
|
||||
|
||||
// return Array.from(uniqueMetrics).map(metric => {
|
||||
// const description = metadataMap.get(metric) || metric;
|
||||
// const safeMetric = this.normalizeIdPart(metric);
|
||||
// const metricRanges = ranges[description] || [];
|
||||
|
||||
// return {
|
||||
// id: `metric_${safeDevice}_${safeModule}_${safeMetric}`,
|
||||
// title: description,
|
||||
// metric,
|
||||
// filters: {
|
||||
// device,
|
||||
// source_id: module
|
||||
// },
|
||||
// ranges: metricRanges,
|
||||
// isDynamic: true,
|
||||
// meta: {
|
||||
// originalDevice: device,
|
||||
// originalModule: module
|
||||
// }
|
||||
// };
|
||||
// });
|
||||
// }
|
||||
|
||||
//ВРЕМЕННЫЙ КОСТЫЛЬ
|
||||
|
||||
private async generateModuleItems(
|
||||
device: string,
|
||||
seriesData: { metric: string; labels: Record<string, string> }[],
|
||||
metadataMap: Map<string, string>
|
||||
): Promise<MenuItem[]> {
|
||||
const modules = new Map<string, string>();
|
||||
const specialFolders = new Map<string, Map<string, string>>();
|
||||
|
||||
seriesData.forEach(({ labels }) => {
|
||||
if (labels.device === device && labels.source_id) {
|
||||
const sourceId = this.normalizeSourceId(labels.source_id);
|
||||
|
||||
if (sourceId.includes(', complex') || sourceId.includes(', integration')) {
|
||||
const [modulePart, folderType] = sourceId.split(', ').map(s => s.trim());
|
||||
let displayName = modulePart;
|
||||
|
||||
if (modulePart.startsWith('module$')) {
|
||||
displayName = `Module ${modulePart.split('$')[1]}`;
|
||||
} else if (modulePart.startsWith('port$')) {
|
||||
displayName = `Port ${modulePart.split('$')[1]}`;
|
||||
} else if (modulePart === 'undefined') {
|
||||
displayName = 'Unknown Module';
|
||||
}
|
||||
|
||||
if (!specialFolders.has(folderType)) {
|
||||
specialFolders.set(folderType, new Map());
|
||||
}
|
||||
specialFolders.get(folderType)!.set(modulePart, displayName);
|
||||
}
|
||||
else if (sourceId.endsWith('complex') || sourceId.endsWith('integration')) {
|
||||
console.warn(`Ignoring legacy format: ${sourceId} for device ${device}`);
|
||||
}
|
||||
else {
|
||||
let displayName = sourceId;
|
||||
if (sourceId.startsWith('module$')) {
|
||||
displayName = `Module ${sourceId.split('$')[1]}`;
|
||||
} else if (sourceId.startsWith('port$')) {
|
||||
displayName = `Port ${sourceId.split('$')[1]}`;
|
||||
}
|
||||
modules.set(sourceId, displayName);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const moduleItems = Array.from(modules.entries()).map(
|
||||
async ([sourceId, displayName]) => ({
|
||||
id: `module_${device}_${this.normalizeIdPart(sourceId)}`,
|
||||
title: displayName,
|
||||
items: await this.generateMetricItems(device, sourceId, seriesData, metadataMap),
|
||||
isDynamic: true
|
||||
})
|
||||
);
|
||||
|
||||
const specialFolderItems = Array.from(specialFolders.entries()).map(
|
||||
async ([folderType, folderModules]) => {
|
||||
const folderItems = await Promise.all(
|
||||
Array.from(folderModules.entries()).map(
|
||||
async ([sourceId, displayName]) => ({
|
||||
id: `module_${device}_${this.normalizeIdPart(sourceId)}_${this.normalizeIdPart(folderType)}`,
|
||||
title: displayName,
|
||||
items: await this.generateMetricItems(
|
||||
device,
|
||||
`${sourceId}, ${folderType}`,
|
||||
seriesData,
|
||||
metadataMap
|
||||
),
|
||||
isDynamic: true
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
return {
|
||||
id: `folder_${device}_${this.normalizeIdPart(folderType)}`,
|
||||
title: folderType,
|
||||
items: folderItems,
|
||||
isDynamic: true
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
return [
|
||||
...(await Promise.all(moduleItems)),
|
||||
...(await Promise.all(specialFolderItems))
|
||||
];
|
||||
}
|
||||
|
||||
private normalizeSourceId(raw: string): string {
|
||||
return raw.split(',').map(s => s.trim()).filter(Boolean).join(', ');
|
||||
}
|
||||
|
||||
private async generateMetricItems(
|
||||
device: string,
|
||||
module: string,
|
||||
seriesData: { metric: string; labels: Record<string, string> }[],
|
||||
metadataMap: Map<string, string>
|
||||
): Promise<MenuItem[]> {
|
||||
const ranges = await this.rangeService.getRanges();
|
||||
|
||||
const normModule = this.normalizeSourceId(module);
|
||||
const isPlainModule = !normModule.includes(',');
|
||||
|
||||
let filtered = seriesData.filter(({ labels }) =>
|
||||
labels.device === device &&
|
||||
this.normalizeSourceId(labels.source_id || '') === normModule
|
||||
);
|
||||
|
||||
if (isPlainModule) {
|
||||
const base = normModule;
|
||||
const shadowSuffixes = ['integration', 'complex'];
|
||||
|
||||
filtered = filtered.filter(entry => {
|
||||
return !shadowSuffixes.some(suffix =>
|
||||
seriesData.some(s =>
|
||||
s.metric === entry.metric &&
|
||||
s.labels.device === device &&
|
||||
this.normalizeSourceId(s.labels.source_id || '') === `${base}, ${suffix}`
|
||||
)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const uniqueMetrics = new Set(filtered.map(entry => entry.metric));
|
||||
const safeDevice = this.normalizeIdPart(device);
|
||||
const safeModule = this.normalizeIdPart(normModule);
|
||||
|
||||
return Array.from(uniqueMetrics).map(metric => {
|
||||
const description = metadataMap.get(metric) || metric;
|
||||
const safeMetric = this.normalizeIdPart(metric);
|
||||
const metricRanges = ranges[description] || [];
|
||||
|
||||
return {
|
||||
id: `metric_${safeDevice}_${safeModule}_${safeMetric}`,
|
||||
title: description,
|
||||
metric,
|
||||
filters: {
|
||||
device,
|
||||
source_id: normModule
|
||||
},
|
||||
ranges: metricRanges,
|
||||
isDynamic: true,
|
||||
meta: {
|
||||
originalDevice: device,
|
||||
originalModule: normModule
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private async injectDynamicItems(
|
||||
menu: MenuItem,
|
||||
dynamicItemsPromise: Promise<MenuItem[]>
|
||||
): Promise<MenuItem> {
|
||||
const dynamicItems = await dynamicItemsPromise;
|
||||
|
||||
if (menu.id === 'media_servers') {
|
||||
return { ...menu, items: dynamicItems };
|
||||
}
|
||||
|
||||
if (menu.items) {
|
||||
const updatedItems = await Promise.all(
|
||||
menu.items.map(item => this.injectDynamicItems(item, dynamicItemsPromise))
|
||||
);
|
||||
return { ...menu, items: updatedItems };
|
||||
}
|
||||
|
||||
return menu;
|
||||
}
|
||||
|
||||
async updateMenuItem(id: string, update: Partial<MenuItem>): Promise<MenuItem> {
|
||||
const existing = this.userOverrides.get(id) || { id };
|
||||
this.userOverrides.set(id, { ...existing, ...update });
|
||||
|
||||
await this.saveUserOverrides();
|
||||
this.invalidateCache();
|
||||
|
||||
const { menu } = await this.getFullMenuWithCache();
|
||||
const updated = this.findMenuItem(menu, id);
|
||||
|
||||
if (!updated) {
|
||||
throw new HttpException('Updated item not found', HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
invalidateCache(): void {
|
||||
this.menuCache = null;
|
||||
this.lastModified = new Date();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import { Controller, Post, Get, Body, HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { RangeService } from './range.service';
|
||||
import { MenuService } from './menu.service';
|
||||
|
||||
@Controller('ranges')
|
||||
export class RangeController {
|
||||
constructor(
|
||||
private readonly rangeService: RangeService,
|
||||
private readonly menuService: MenuService
|
||||
) { }
|
||||
|
||||
@Get('list')
|
||||
async getRanges() {
|
||||
try {
|
||||
return await this.rangeService.getRanges();
|
||||
} catch (error) {
|
||||
throw new HttpException('Failed to fetch ranges', HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
@Post('update')
|
||||
async updateRanges(
|
||||
@Body() data: Array<{ name: string; ranges: { min: number; max: number; status: number }[] }>
|
||||
) {
|
||||
if (!Array.isArray(data)) {
|
||||
throw new HttpException('Invalid data format', HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.rangeService.updateRanges(data);
|
||||
this.menuService.invalidateCache();
|
||||
return result;
|
||||
} catch (error) {
|
||||
throw new HttpException(error.message, HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { HttpService } from '@nestjs/axios';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
@Injectable()
|
||||
export class RangeService {
|
||||
private readonly rangesApiUrl: string;
|
||||
private readonly rangesApiEndpoint: string;
|
||||
|
||||
constructor(
|
||||
private readonly httpService: HttpService,
|
||||
private readonly configService: ConfigService
|
||||
) {
|
||||
this.rangesApiUrl = this.configService.get<string>('RANGES_API_URL', 'localhost:3000');
|
||||
this.rangesApiEndpoint = this.configService.get<string>('RANGES_API_ENDPOINT', 'localhost:3000');
|
||||
}
|
||||
|
||||
async getRanges(): Promise<Record<string, Array<{ min: number; max: number; status: number }>>> {
|
||||
try {
|
||||
const response = await firstValueFrom(
|
||||
this.httpService.request({
|
||||
method: 'OPTIONS',
|
||||
url: `${this.rangesApiUrl}${this.rangesApiEndpoint}`,
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Проверяем, что ответ содержит данные в ожидаемом формате
|
||||
if (!response.data || !Array.isArray(response.data)) {
|
||||
console.error('Invalid response format from ranges API', response.data);
|
||||
return {};
|
||||
}
|
||||
|
||||
const rangesMap: Record<string, Array<{ min: number; max: number; status: number }>> = {};
|
||||
|
||||
response.data.forEach(item => {
|
||||
if (item.name && Array.isArray(item.ranges)) {
|
||||
rangesMap[item.name] = item.ranges;
|
||||
}
|
||||
});
|
||||
|
||||
return rangesMap;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch ranges:', error);
|
||||
|
||||
// Детальное логирование ошибки
|
||||
if (error.response) {
|
||||
console.error('Server responded with:', {
|
||||
status: error.response.status,
|
||||
data: error.response.data
|
||||
});
|
||||
} else if (error.request) {
|
||||
console.error('No response received:', error.request);
|
||||
} else {
|
||||
console.error('Request setup error:', error.message);
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async updateRanges(data: Array<{ name: string; ranges: { min: number; max: number; status: number }[] }>) {
|
||||
try {
|
||||
const response = await firstValueFrom(
|
||||
this.httpService.post(`${this.rangesApiUrl}${this.rangesApiEndpoint}`, data, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Failed to update ranges:', error);
|
||||
throw new Error('Failed to update ranges');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
import { Controller, Get, Query } from '@nestjs/common';
|
||||
import { PrometheusService } from './prometheus.service';
|
||||
|
||||
@Controller('metrics')
|
||||
export class MetricsController {
|
||||
constructor(private readonly prometheusService: PrometheusService) { }
|
||||
|
||||
@Get()
|
||||
async getMetrics(
|
||||
@Query('metric') metric: string,
|
||||
@Query('start') start: number,
|
||||
@Query('end') end: number,
|
||||
@Query('step') step: number,
|
||||
) {
|
||||
if (start && end && step) {
|
||||
return this.prometheusService.fetchMetricsRange(metric, start, end, step);
|
||||
}
|
||||
return this.prometheusService.fetchMetrics(metric);
|
||||
}
|
||||
|
||||
@Get('/all')
|
||||
async getAllMetrics() {
|
||||
return this.prometheusService.fetchAllMetrics();
|
||||
}
|
||||
|
||||
@Get('/all-values')
|
||||
async getAllMetricsWithValues() {
|
||||
return this.prometheusService.fetchAllMetricsWithValues();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
export interface PrometheusMetric {
|
||||
__name__?: string;
|
||||
[key: string]: string | number | undefined;
|
||||
timestamp: number;
|
||||
value: number;
|
||||
type: string; // Тип метрики ("gauge", "counter", и т. д.)
|
||||
description?: string; // Описание метрики
|
||||
}
|
||||
|
|
@ -1,117 +0,0 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { HttpService } from '@nestjs/axios';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { lastValueFrom } from 'rxjs';
|
||||
import { PrometheusMetric } from './prometheus-metric.interface';
|
||||
|
||||
@Injectable()
|
||||
export class PrometheusService {
|
||||
private readonly prometheusUrl: string;
|
||||
|
||||
constructor(
|
||||
private readonly httpService: HttpService,
|
||||
private readonly configService: ConfigService
|
||||
) {
|
||||
this.prometheusUrl = this.configService.get<string>('PROMETHEUS_API', 'http://localhost:9090');
|
||||
console.log('Prometheus API URL:', this.prometheusUrl);
|
||||
}
|
||||
|
||||
// Получаем тип метрики
|
||||
async fetchMetricType(metric: string): Promise<string | null> {
|
||||
try {
|
||||
const response = await lastValueFrom(
|
||||
this.httpService.get(`${this.prometheusUrl}/metadata`, {
|
||||
params: { metric },
|
||||
})
|
||||
);
|
||||
|
||||
const metadata = response.data.data[metric];
|
||||
return metadata?.length ? metadata[0].type : null;
|
||||
} catch (error) {
|
||||
console.error(`Ошибка при получении типа метрики ${metric}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Получаем описание метрики
|
||||
async fetchMetricDescription(metric: string): Promise<string | undefined> {
|
||||
try {
|
||||
const response = await lastValueFrom(
|
||||
this.httpService.get(`${this.prometheusUrl}/metadata`, {
|
||||
params: { metric },
|
||||
})
|
||||
);
|
||||
|
||||
const metadata = response.data.data[metric];
|
||||
return metadata?.length ? metadata[0].help : undefined;
|
||||
} catch (error) {
|
||||
console.error(`Ошибка при получении описания метрики ${metric}:`, error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// Получаем данные метрики (текущие значения)
|
||||
async fetchMetrics(metric: string): Promise<PrometheusMetric[]> {
|
||||
const response = await lastValueFrom(
|
||||
this.httpService.get(`${this.prometheusUrl}/query`, {
|
||||
params: { query: metric },
|
||||
})
|
||||
);
|
||||
|
||||
const metricType = await this.fetchMetricType(metric);
|
||||
const metricDescription = await this.fetchMetricDescription(metric);
|
||||
|
||||
return response.data.data.result.map((entry): PrometheusMetric => ({
|
||||
...entry.metric,
|
||||
timestamp: entry.value[0] * 1000,
|
||||
value: parseFloat(entry.value[1]),
|
||||
type: metricType || 'unknown',
|
||||
description: metricDescription, // Добавляем описание
|
||||
}));
|
||||
}
|
||||
|
||||
// Получаем данные метрики за интервал
|
||||
async fetchMetricsRange(metric: string, start: number, end: number, step: number): Promise<PrometheusMetric[]> {
|
||||
const response = await lastValueFrom(
|
||||
this.httpService.get(`${this.prometheusUrl}/query_range`, {
|
||||
params: {
|
||||
query: metric,
|
||||
start,
|
||||
end,
|
||||
step,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const metricType = await this.fetchMetricType(metric);
|
||||
const metricDescription = await this.fetchMetricDescription(metric);
|
||||
|
||||
return response.data.data.result.flatMap((entry) =>
|
||||
entry.values.map((value): PrometheusMetric => ({
|
||||
...entry.metric,
|
||||
timestamp: value[0] * 1000,
|
||||
value: parseFloat(value[1]),
|
||||
type: metricType || 'unknown',
|
||||
description: metricDescription, // Добавляем описание
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
// Получаем список всех метрик
|
||||
async fetchAllMetrics(): Promise<string[]> {
|
||||
const response = await lastValueFrom(
|
||||
this.httpService.get(`${this.prometheusUrl}/label/__name__/values`)
|
||||
);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
// Получаем все метрики с их значениями
|
||||
async fetchAllMetricsWithValues(): Promise<any[]> {
|
||||
const metricNames = await this.fetchAllMetrics();
|
||||
const promises = metricNames.map(async (metric) => {
|
||||
const data = await this.fetchMetrics(metric);
|
||||
return { metric, data };
|
||||
});
|
||||
return Promise.all(promises);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
// export * from './prometheus-metric.interface';
|
||||
// export * from './prometheus.service';
|
||||
// export * from './prometheus-cache.service';
|
||||
// export * from './prometheus-query.service';
|
||||
// export * from './prometheus-metadata.service';
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
import { Controller, Get, Query } from '@nestjs/common';
|
||||
import { PrometheusService } from './prometheus.service';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiQuery } from '@nestjs/swagger';
|
||||
|
||||
@ApiTags('Metrics - HTTP')
|
||||
@Controller('metrics')
|
||||
export class MetricsController {
|
||||
constructor(private readonly prometheusService: PrometheusService) { }
|
||||
|
||||
@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(
|
||||
@Query('metric') metric: string,
|
||||
@Query('start') start: number,
|
||||
@Query('end') end: number,
|
||||
@Query('step') step: number,
|
||||
) {
|
||||
if (start && end && step) {
|
||||
return this.prometheusService.fetchMetricsRange(metric, start, end, step);
|
||||
}
|
||||
return this.prometheusService.fetchMetrics(metric);
|
||||
}
|
||||
|
||||
@Get('/all')
|
||||
@ApiOperation({ summary: 'Получение списка всех метрик' })
|
||||
@ApiResponse({ status: 200, description: 'Список извлеченных метрик' })
|
||||
async getAllMetrics() {
|
||||
return this.prometheusService.fetchAllMetrics();
|
||||
}
|
||||
|
||||
@Get('/all-values')
|
||||
@ApiOperation({ summary: 'Получение списка всех метрик и их значения' })
|
||||
@ApiResponse({ status: 200, description: 'Все метрики с полученными значениями' })
|
||||
async getAllMetricsWithValues() {
|
||||
return this.prometheusService.fetchAllMetricsWithValues();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,470 @@
|
|||
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import { Server, WebSocket } from 'ws';
|
||||
import { createServer } from 'http';
|
||||
import { PrometheusService } from './prometheus.service';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PrometheusMetric } from './prometheus-metric.interface';
|
||||
|
||||
type Filters = Record<string, string>;
|
||||
|
||||
interface RealtimeSubscription {
|
||||
clients: Set<string>;
|
||||
interval: NodeJS.Timeout;
|
||||
metric: string;
|
||||
filters: Filters;
|
||||
lastData: PrometheusMetric[];
|
||||
}
|
||||
|
||||
interface HistoricalRequest {
|
||||
client: WebSocket;
|
||||
requestId: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class MetricsGateway implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(MetricsGateway.name);
|
||||
private wss: Server;
|
||||
private httpServer: ReturnType<typeof createServer>;
|
||||
|
||||
// Real-time подписки (одна на метрику, много клиентов)
|
||||
private realtimeSubscriptions = new Map<string, RealtimeSubscription>();
|
||||
|
||||
// Активные клиенты
|
||||
private activeClients = new Map<string, WebSocket>();
|
||||
|
||||
// Исторические запросы (для отслеживания)
|
||||
private historicalRequests = new Map<string, HistoricalRequest>();
|
||||
|
||||
constructor(
|
||||
private readonly prometheusService: PrometheusService,
|
||||
private readonly configService: ConfigService
|
||||
) { }
|
||||
|
||||
onModuleInit() {
|
||||
this.httpServer = createServer();
|
||||
this.wss = new Server({
|
||||
server: this.httpServer,
|
||||
path: '/metrics-ws',
|
||||
});
|
||||
|
||||
this.wss.on('connection', (client, request) =>
|
||||
this.handleConnection(client, request)
|
||||
);
|
||||
|
||||
this.wss.on('error', (err) =>
|
||||
this.logger.error('WebSocket server error:', err)
|
||||
);
|
||||
|
||||
const wsPort = Number(this.configService.get('WS_PORT') || 3001);
|
||||
const wsHost = this.configService.get('WS_HOST') || '0.0.0.0';
|
||||
|
||||
this.httpServer.listen(wsPort, wsHost, () => {
|
||||
this.logger.log(
|
||||
`WebSocket server running at ws://${wsHost}:${wsPort}/metrics-ws`
|
||||
);
|
||||
});
|
||||
}
|
||||
onModuleDestroy() {
|
||||
// Очистка всех ресурсов
|
||||
this.clearAllSubscriptions();
|
||||
this.wss?.close();
|
||||
this.httpServer?.close();
|
||||
}
|
||||
|
||||
private handleConnection(client: WebSocket, request: any) {
|
||||
const clientId = this.getClientId(request?.url);
|
||||
this.activeClients.set(clientId, client);
|
||||
|
||||
this.logger.log(`Client connected: ${clientId}`);
|
||||
this.logger.debug(`Active clients: ${this.activeClients.size}, Subscriptions: ${this.realtimeSubscriptions.size}`);
|
||||
|
||||
client.on('message', (raw) => {
|
||||
try {
|
||||
const message = JSON.parse(raw.toString());
|
||||
this.handleMessage(clientId, client, message);
|
||||
} catch (err) {
|
||||
this.sendError(client, 'Invalid JSON format');
|
||||
}
|
||||
});
|
||||
|
||||
client.on('close', () => this.handleClientDisconnect(clientId));
|
||||
client.on('error', (err) => {
|
||||
this.logger.error(`Client ${clientId} error:`, err);
|
||||
this.handleClientDisconnect(clientId);
|
||||
});
|
||||
|
||||
// Отправляем приветственное сообщение
|
||||
this.sendMessage(client, {
|
||||
event: 'connected',
|
||||
data: { clientId, timestamp: Date.now() }
|
||||
});
|
||||
}
|
||||
|
||||
private handleMessage(clientId: string, client: WebSocket, message: any) {
|
||||
const { event, data, requestId } = message;
|
||||
|
||||
if (!event) {
|
||||
return this.sendError(client, 'Event type is required', requestId);
|
||||
}
|
||||
|
||||
this.logger.debug(`Received event: ${event} from client: ${clientId}`);
|
||||
|
||||
switch (event) {
|
||||
case 'subscribe-realtime':
|
||||
return this.handleSubscribeRealtime(clientId, client, data, requestId);
|
||||
|
||||
case 'unsubscribe-realtime':
|
||||
return this.handleUnsubscribeRealtime(clientId, data, requestId);
|
||||
|
||||
case 'unsubscribe-all':
|
||||
return this.handleUnsubscribeAll(clientId, requestId);
|
||||
|
||||
case 'get-historical':
|
||||
return this.handleGetHistorical(client, data, requestId);
|
||||
|
||||
case 'get-current':
|
||||
return this.handleGetCurrent(client, data, requestId);
|
||||
|
||||
default:
|
||||
return this.sendError(client, `Unknown event type: ${event}`, requestId);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleSubscribeRealtime(
|
||||
clientId: string,
|
||||
client: WebSocket,
|
||||
payload: any,
|
||||
requestId?: string
|
||||
) {
|
||||
const { metric, filters = {}, interval = 10000 } = payload || {};
|
||||
|
||||
if (!metric) {
|
||||
return this.sendError(client, 'Metric name is required', requestId);
|
||||
}
|
||||
|
||||
const subscriptionKey = this.getSubscriptionKey(metric, filters);
|
||||
|
||||
try {
|
||||
// Если подписка уже существует, просто добавляем клиента
|
||||
if (this.realtimeSubscriptions.has(subscriptionKey)) {
|
||||
const subscription = this.realtimeSubscriptions.get(subscriptionKey)!;
|
||||
subscription.clients.add(clientId);
|
||||
|
||||
this.logger.log(`Client ${clientId} added to existing subscription: ${subscriptionKey}`);
|
||||
|
||||
// Отправляем последние данные клиенту
|
||||
if (subscription.lastData) {
|
||||
this.sendMessage(client, {
|
||||
event: 'realtime-data',
|
||||
data: {
|
||||
metric,
|
||||
filters,
|
||||
data: subscription.lastData,
|
||||
type: 'initial'
|
||||
},
|
||||
requestId
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Создаем новую подписку
|
||||
this.logger.log(`Creating new realtime subscription: ${subscriptionKey}`);
|
||||
|
||||
// Первоначальная загрузка данных
|
||||
const initialData = await this.prometheusService.fetchMetricsWithFilters(metric, filters);
|
||||
|
||||
if (!Array.isArray(initialData)) {
|
||||
throw new Error(`Expected array for metric ${metric}, got ${typeof initialData}`);
|
||||
}
|
||||
|
||||
// Создаем интервал для обновлений
|
||||
const intervalId = setInterval(
|
||||
() => this.updateRealtimeData(subscriptionKey),
|
||||
interval
|
||||
);
|
||||
|
||||
// Сохраняем подписку
|
||||
const subscription: RealtimeSubscription = {
|
||||
clients: new Set([clientId]),
|
||||
interval: intervalId,
|
||||
metric,
|
||||
filters,
|
||||
lastData: initialData
|
||||
};
|
||||
|
||||
this.realtimeSubscriptions.set(subscriptionKey, subscription);
|
||||
|
||||
// Отправляем данные клиенту
|
||||
this.sendMessage(client, {
|
||||
event: 'realtime-data',
|
||||
data: {
|
||||
metric,
|
||||
filters,
|
||||
data: initialData,
|
||||
type: 'initial'
|
||||
},
|
||||
requestId
|
||||
});
|
||||
|
||||
this.logger.debug(`Subscription created for ${subscriptionKey} with ${interval}ms interval`);
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error(`Subscribe error for ${subscriptionKey}:`, error);
|
||||
this.sendError(client, error.message, requestId);
|
||||
}
|
||||
}
|
||||
|
||||
private async updateRealtimeData(subscriptionKey: string) {
|
||||
const subscription = this.realtimeSubscriptions.get(subscriptionKey);
|
||||
if (!subscription) return;
|
||||
|
||||
try {
|
||||
const freshData = await this.prometheusService.fetchMetricsWithFilters(
|
||||
subscription.metric,
|
||||
subscription.filters
|
||||
);
|
||||
|
||||
if (!this.isDataEqual(subscription.lastData, freshData)) {
|
||||
subscription.lastData = freshData;
|
||||
|
||||
// Рассылаем обновление всем подписанным клиентам
|
||||
this.broadcastToClients(Array.from(subscription.clients), {
|
||||
event: 'realtime-data',
|
||||
data: {
|
||||
metric: subscription.metric,
|
||||
filters: subscription.filters,
|
||||
data: freshData,
|
||||
type: 'update'
|
||||
}
|
||||
});
|
||||
|
||||
this.logger.debug(`Data updated for subscription: ${subscriptionKey}`);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`Update error for ${subscriptionKey}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
private handleUnsubscribeRealtime(
|
||||
clientId: string,
|
||||
payload: any,
|
||||
requestId?: string
|
||||
) {
|
||||
const { metric, filters = {} } = payload || {};
|
||||
|
||||
if (!metric) {
|
||||
return;
|
||||
}
|
||||
|
||||
const subscriptionKey = this.getSubscriptionKey(metric, filters);
|
||||
const subscription = this.realtimeSubscriptions.get(subscriptionKey);
|
||||
|
||||
if (!subscription) {
|
||||
this.logger.debug(`No subscription found for: ${subscriptionKey}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Удаляем клиента из подписки
|
||||
subscription.clients.delete(clientId);
|
||||
this.logger.log(`Client ${clientId} unsubscribed from: ${subscriptionKey}`);
|
||||
|
||||
// Если больше нет клиентов, очищаем подписку
|
||||
if (subscription.clients.size === 0) {
|
||||
clearInterval(subscription.interval);
|
||||
this.realtimeSubscriptions.delete(subscriptionKey);
|
||||
this.logger.log(`Subscription removed: ${subscriptionKey}`);
|
||||
}
|
||||
}
|
||||
|
||||
private handleUnsubscribeAll(clientId: string, requestId?: string) {
|
||||
let unsubscribedCount = 0;
|
||||
|
||||
for (const [key, subscription] of this.realtimeSubscriptions) {
|
||||
if (subscription.clients.has(clientId)) {
|
||||
subscription.clients.delete(clientId);
|
||||
unsubscribedCount++;
|
||||
|
||||
if (subscription.clients.size === 0) {
|
||||
clearInterval(subscription.interval);
|
||||
this.realtimeSubscriptions.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(`Client ${clientId} unsubscribed from ${unsubscribedCount} subscriptions`);
|
||||
}
|
||||
|
||||
private async handleGetHistorical(
|
||||
client: WebSocket,
|
||||
payload: any,
|
||||
requestId?: string
|
||||
) {
|
||||
const { metric, start, end, step = 60, filters = {} } = payload || {};
|
||||
|
||||
if (!metric) {
|
||||
return this.sendError(client, 'Metric name is required', requestId);
|
||||
}
|
||||
|
||||
if (!start || !end) {
|
||||
return this.sendError(client, 'Start and end time are required', requestId);
|
||||
}
|
||||
|
||||
const requestKey = `${requestId || Date.now()}-${metric}`;
|
||||
|
||||
try {
|
||||
this.logger.debug(`Fetching historical data for: ${metric}, from ${new Date(start).toISOString()} to ${new Date(end).toISOString()}`);
|
||||
|
||||
const historicalData = await this.prometheusService.fetchMetricsRange(
|
||||
metric,
|
||||
Math.floor(start / 1000), // Convert to seconds
|
||||
Math.floor(end / 1000), // Convert to seconds
|
||||
step,
|
||||
filters
|
||||
);
|
||||
|
||||
this.sendMessage(client, {
|
||||
event: 'historical-data',
|
||||
data: {
|
||||
metric,
|
||||
filters,
|
||||
data: historicalData,
|
||||
start,
|
||||
end,
|
||||
step
|
||||
},
|
||||
requestId
|
||||
});
|
||||
|
||||
this.logger.debug(`Historical data sent for: ${metric}, points: ${historicalData.length}`);
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error(`Historical data error for ${metric}:`, error);
|
||||
this.sendError(client, error.message, requestId);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleGetCurrent(
|
||||
client: WebSocket,
|
||||
payload: any,
|
||||
requestId?: string
|
||||
) {
|
||||
const { metric, filters = {} } = payload || {};
|
||||
|
||||
if (!metric) {
|
||||
return this.sendError(client, 'Metric name is required', requestId);
|
||||
}
|
||||
|
||||
try {
|
||||
const currentData = await this.prometheusService.fetchMetricsWithFilters(metric, filters);
|
||||
|
||||
this.sendMessage(client, {
|
||||
event: 'current-data',
|
||||
data: {
|
||||
metric,
|
||||
filters,
|
||||
data: currentData,
|
||||
timestamp: Date.now()
|
||||
},
|
||||
requestId
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error(`Current data error for ${metric}:`, error);
|
||||
this.sendError(client, error.message, requestId);
|
||||
}
|
||||
}
|
||||
|
||||
private handleClientDisconnect(clientId: string) {
|
||||
this.logger.log(`Client disconnected: ${clientId}`);
|
||||
|
||||
// Удаляем клиента из всех подписок
|
||||
this.handleUnsubscribeAll(clientId);
|
||||
|
||||
// Удаляем из активных клиентов
|
||||
this.activeClients.delete(clientId);
|
||||
|
||||
this.logger.debug(`Active clients: ${this.activeClients.size}, Subscriptions: ${this.realtimeSubscriptions.size}`);
|
||||
}
|
||||
|
||||
private clearAllSubscriptions() {
|
||||
for (const [key, subscription] of this.realtimeSubscriptions) {
|
||||
clearInterval(subscription.interval);
|
||||
}
|
||||
this.realtimeSubscriptions.clear();
|
||||
this.logger.log('All subscriptions cleared');
|
||||
}
|
||||
|
||||
private getClientId(url?: string): string {
|
||||
const params = this.getQueryParams(url);
|
||||
return params.clientId || `client-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||
}
|
||||
|
||||
private getQueryParams(url?: string): Record<string, string> {
|
||||
try {
|
||||
if (!url) return {};
|
||||
const urlObj = new URL(url, 'http://localhost');
|
||||
return Object.fromEntries(urlObj.searchParams.entries());
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
private getSubscriptionKey(metric: string, filters: Filters): string {
|
||||
const sortedFilters = Object.keys(filters)
|
||||
.sort()
|
||||
.map(key => `${key}=${encodeURIComponent(filters[key])}`)
|
||||
.join('&');
|
||||
|
||||
return sortedFilters ? `${metric}?${sortedFilters}` : metric;
|
||||
}
|
||||
|
||||
private isDataEqual(a: PrometheusMetric[], b: PrometheusMetric[]): boolean {
|
||||
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return a.every((itemA, index) => {
|
||||
const itemB = b[index];
|
||||
return (
|
||||
itemA.value === itemB.value &&
|
||||
itemA.timestamp === itemB.timestamp &&
|
||||
itemA.device === itemB.device &&
|
||||
itemA.source_id === itemB.source_id
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private sendMessage(client: WebSocket, message: any) {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
client.send(JSON.stringify(message));
|
||||
} catch (error) {
|
||||
this.logger.error('Error sending message to client:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private broadcastToClients(clientIds: string[], message: any) {
|
||||
const messageStr = JSON.stringify(message);
|
||||
|
||||
clientIds.forEach(clientId => {
|
||||
const client = this.activeClients.get(clientId);
|
||||
if (client && client.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
client.send(messageStr);
|
||||
} catch (error) {
|
||||
this.logger.error(`Error broadcasting to client ${clientId}:`, error);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private sendError(client: WebSocket, error: string, requestId?: string) {
|
||||
this.sendMessage(client, {
|
||||
event: 'error',
|
||||
data: { error, requestId },
|
||||
requestId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { PrometheusMetric } from './prometheus-metric.interface';
|
||||
|
||||
interface CacheEntry<T> {
|
||||
data: T;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface MetadataCacheEntry {
|
||||
type: string | null;
|
||||
description: string | undefined;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PrometheusCacheService {
|
||||
private metricCache = new Map<string, CacheEntry<PrometheusMetric[]>>();
|
||||
private metadataCache = new Map<string, MetadataCacheEntry>();
|
||||
|
||||
getMetricCache(key: string): CacheEntry<PrometheusMetric[]> | undefined {
|
||||
return this.metricCache.get(key);
|
||||
}
|
||||
|
||||
setMetricCache(key: string, data: PrometheusMetric[], ttl: number = 5000): void {
|
||||
this.metricCache.set(key, { data, timestamp: Date.now() + ttl });
|
||||
}
|
||||
|
||||
getMetadataCache(key: string): MetadataCacheEntry | undefined {
|
||||
return this.metadataCache.get(key);
|
||||
}
|
||||
|
||||
setMetadataCache(
|
||||
key: string,
|
||||
type: string | null,
|
||||
description: string | undefined,
|
||||
ttl: number = 30000
|
||||
): void {
|
||||
this.metadataCache.set(key, { type, description, timestamp: Date.now() + ttl });
|
||||
}
|
||||
|
||||
isCacheValid(cacheEntry: { timestamp: number }): boolean {
|
||||
return Date.now() < cacheEntry.timestamp;
|
||||
}
|
||||
|
||||
clearCache(): void {
|
||||
this.metricCache.clear();
|
||||
this.metadataCache.clear();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
export interface PrometheusMetric {
|
||||
__name__: string;
|
||||
device: string;
|
||||
source_id: string;
|
||||
value: number;
|
||||
timestamp: number;
|
||||
type?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class PrometheusQueryService {
|
||||
buildFilteredQuery(metric: string, filters: Record<string, string>): string {
|
||||
const filterParts = Object.entries(filters)
|
||||
.filter(([_, value]) => value !== undefined && value !== null && value !== "")
|
||||
.map(([key, value]) => `${key}="${value}"`);
|
||||
|
||||
return filterParts.length > 0
|
||||
? `${metric}{${filterParts.join(',')}}`
|
||||
: metric;
|
||||
}
|
||||
|
||||
calculateOptimalStep(start: number, end: number): number {
|
||||
const duration = end - start;
|
||||
return Math.max(Math.floor(duration / 1000), 15);
|
||||
}
|
||||
|
||||
generateCacheKey(metric: string, filters: Record<string, string> = {}): string {
|
||||
return `${metric}:${JSON.stringify(filters)}`;
|
||||
}
|
||||
|
||||
generateMetadataCacheKey(metric: string, type: 'type' | 'description'): string {
|
||||
return `metadata-${type}-${metric}`;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { PrometheusService } from './prometheus.service';
|
||||
import { MetricsController } from './metrics.controller';
|
||||
import { MetricsGateway } from './metrics.gateway';
|
||||
import { PrometheusCacheService } from './prometheus-cache.service';
|
||||
import { PrometheusQueryService } from './prometheus-query.service';
|
||||
|
||||
@Module({
|
||||
imports: [HttpModule, ConfigModule],
|
||||
providers: [
|
||||
PrometheusCacheService,
|
||||
PrometheusQueryService,
|
||||
PrometheusService,
|
||||
MetricsGateway
|
||||
],
|
||||
controllers: [MetricsController],
|
||||
exports: [PrometheusService]
|
||||
})
|
||||
export class PrometheusModule { }
|
||||
|
|
@ -0,0 +1,288 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { HttpService } from '@nestjs/axios';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { lastValueFrom } from 'rxjs';
|
||||
import { MenuItem } from '../menu/menu.interface';
|
||||
import { PrometheusMetric } from './prometheus-metric.interface';
|
||||
import { PrometheusCacheService } from './prometheus-cache.service';
|
||||
import { PrometheusQueryService } from './prometheus-query.service';
|
||||
|
||||
interface PrometheusResponse {
|
||||
status: string;
|
||||
data: any;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PrometheusService {
|
||||
private readonly prometheusUrl: string;
|
||||
|
||||
constructor(
|
||||
private readonly httpService: HttpService,
|
||||
private readonly configService: ConfigService,
|
||||
private readonly cacheService: PrometheusCacheService,
|
||||
private readonly queryService: PrometheusQueryService
|
||||
) {
|
||||
this.prometheusUrl = this.configService.get<string>('PROMETHEUS_API', 'http://localhost:9090');
|
||||
console.log('Prometheus API URL:', this.prometheusUrl);
|
||||
}
|
||||
|
||||
private async executeQuery(url: string, params: any): Promise<any> {
|
||||
const response = await lastValueFrom(
|
||||
this.httpService.get(url, { params })
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async fetchMetricType(metric: string): Promise<string | null> {
|
||||
const cacheKey = this.queryService.generateMetadataCacheKey(metric, 'type');
|
||||
const cacheEntry = this.cacheService.getMetadataCache(cacheKey);
|
||||
|
||||
if (cacheEntry && this.cacheService.isCacheValid(cacheEntry)) {
|
||||
return cacheEntry.type;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await this.executeQuery(`${this.prometheusUrl}/metadata`, {
|
||||
params: { metric },
|
||||
});
|
||||
|
||||
const metadata = data.data[metric];
|
||||
const result = metadata?.length ? metadata[0].type : null;
|
||||
|
||||
this.cacheService.setMetadataCache(cacheKey, result, cacheEntry?.description);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(`Ошибка при получении типа метрики ${metric}:`, error);
|
||||
return cacheEntry?.type || null;
|
||||
}
|
||||
}
|
||||
|
||||
async fetchMetricDescription(metric: string): Promise<string | undefined> {
|
||||
const cacheKey = this.queryService.generateMetadataCacheKey(metric, 'description');
|
||||
const cacheEntry = this.cacheService.getMetadataCache(cacheKey);
|
||||
|
||||
if (cacheEntry && this.cacheService.isCacheValid(cacheEntry)) {
|
||||
return cacheEntry.description;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await this.executeQuery(`${this.prometheusUrl}/metadata`, {
|
||||
params: { metric },
|
||||
});
|
||||
|
||||
const metadata = data.data[metric];
|
||||
const result = metadata?.length ? metadata[0].help : undefined;
|
||||
|
||||
this.cacheService.setMetadataCache(cacheKey, cacheEntry?.type ?? null, result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(`Ошибка при получении описания метрики ${metric}:`, error);
|
||||
return cacheEntry?.description;
|
||||
}
|
||||
}
|
||||
|
||||
private transformMetricData(
|
||||
entry: any,
|
||||
metric: string,
|
||||
type: string | null,
|
||||
description: string | undefined
|
||||
): PrometheusMetric {
|
||||
return {
|
||||
__name__: entry.metric.__name__ || metric,
|
||||
device: entry.metric.device || '',
|
||||
source_id: entry.metric.source_id || '',
|
||||
value: parseFloat(entry.value[1]),
|
||||
timestamp: entry.value[0] * 1000,
|
||||
type: type || 'gauge',
|
||||
description
|
||||
};
|
||||
}
|
||||
|
||||
async fetchMetrics(metric: string): Promise<PrometheusMetric[]> {
|
||||
const cacheKey = this.queryService.generateCacheKey(metric);
|
||||
const cacheEntry = this.cacheService.getMetricCache(cacheKey);
|
||||
|
||||
if (cacheEntry && this.cacheService.isCacheValid(cacheEntry)) {
|
||||
return cacheEntry.data;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await this.executeQuery(`${this.prometheusUrl}/query`, {
|
||||
query: metric
|
||||
});
|
||||
|
||||
const [type, description] = await Promise.all([
|
||||
this.fetchMetricType(metric),
|
||||
this.fetchMetricDescription(metric)
|
||||
]);
|
||||
|
||||
const result = data.data.result.map((entry: any) =>
|
||||
this.transformMetricData(entry, metric, type, description)
|
||||
);
|
||||
|
||||
this.cacheService.setMetricCache(cacheKey, result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(`Error fetching metrics for ${metric}:`, error);
|
||||
if (cacheEntry) return cacheEntry.data;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async fetchMetricsWithFilters(metric: string, filters: Record<string, string>): Promise<PrometheusMetric[]> {
|
||||
const cacheKey = this.queryService.generateCacheKey(metric, filters);
|
||||
const cacheEntry = this.cacheService.getMetricCache(cacheKey);
|
||||
|
||||
if (cacheEntry && this.cacheService.isCacheValid(cacheEntry)) {
|
||||
return cacheEntry.data;
|
||||
}
|
||||
|
||||
try {
|
||||
const query = this.queryService.buildFilteredQuery(metric, filters);
|
||||
const data = await this.executeQuery(`${this.prometheusUrl}/query`, { query });
|
||||
|
||||
const [type, description] = await Promise.all([
|
||||
this.fetchMetricType(metric),
|
||||
this.fetchMetricDescription(metric)
|
||||
]);
|
||||
|
||||
const result = data.data.result.map((entry: any) =>
|
||||
this.transformMetricData(entry, metric, type, description)
|
||||
);
|
||||
|
||||
this.cacheService.setMetricCache(cacheKey, result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(`Error fetching metrics with filters for ${metric}:`, error);
|
||||
if (cacheEntry) return cacheEntry.data;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async fetchMetricsRange(
|
||||
metric: string,
|
||||
start: number,
|
||||
end: number,
|
||||
step: number,
|
||||
filters: Record<string, string> = {}
|
||||
): Promise<PrometheusMetric[]> {
|
||||
const query = this.queryService.buildFilteredQuery(metric, {
|
||||
...filters,
|
||||
instance: process.env.PROMETHEUS_INSTANCE || ""
|
||||
});
|
||||
|
||||
const optimalStep = this.queryService.calculateOptimalStep(start, end);
|
||||
|
||||
try {
|
||||
const data = await this.executeQuery(`${this.prometheusUrl}/query_range`, {
|
||||
query,
|
||||
start,
|
||||
end,
|
||||
step: optimalStep.toString()
|
||||
});
|
||||
|
||||
const [type, description] = await Promise.all([
|
||||
this.fetchMetricType(metric),
|
||||
this.fetchMetricDescription(metric)
|
||||
]);
|
||||
|
||||
return data.data.result.flatMap((entry: any) =>
|
||||
entry.values.map((value: any) => ({
|
||||
__name__: entry.metric.__name__ || metric,
|
||||
device: entry.metric.device || '',
|
||||
source_id: entry.metric.source_id || '',
|
||||
value: parseFloat(value[1]),
|
||||
timestamp: value[0] * 1000,
|
||||
type: type || 'gauge',
|
||||
description
|
||||
}))
|
||||
);
|
||||
} 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);
|
||||
}
|
||||
|
||||
async fetchMetricMetadata(metric: string): Promise<{
|
||||
name: string;
|
||||
help?: string;
|
||||
type?: string;
|
||||
}> {
|
||||
try {
|
||||
const data = await this.executeQuery(`${this.prometheusUrl}/metadata`, {
|
||||
params: { metric }
|
||||
});
|
||||
|
||||
const metadata = data?.data?.[metric]?.[0];
|
||||
|
||||
return {
|
||||
name: metric,
|
||||
help: metadata?.help,
|
||||
type: metadata?.type
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Error fetching metadata for ${metric}:`, error);
|
||||
return { name: metric };
|
||||
}
|
||||
}
|
||||
|
||||
async fetchMetricSeries(metric: string): Promise<Record<string, string>[]> {
|
||||
try {
|
||||
const data = await this.executeQuery(`${this.prometheusUrl}/series`, {
|
||||
'match[]': metric
|
||||
});
|
||||
|
||||
return data.data || [];
|
||||
} catch (error) {
|
||||
console.error(`Error fetching series for ${metric}:`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async fetchAllMetrics(): Promise<string[]> {
|
||||
try {
|
||||
const data = await this.executeQuery(`${this.prometheusUrl}/label/__name__/values`, {});
|
||||
return data.data;
|
||||
} catch (error) {
|
||||
console.error('Error fetching all metrics:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async fetchAllMetricsWithValues(): Promise<Array<{ metric: string; data: PrometheusMetric[] }>> {
|
||||
const metricNames = await this.fetchAllMetrics();
|
||||
const zvksMetrics = metricNames.filter(metric =>
|
||||
metric.startsWith('zvks') ||
|
||||
metric.includes('server_li') ||
|
||||
metric.includes('application_li')
|
||||
);
|
||||
|
||||
const promises = zvksMetrics.map(async (metric) => {
|
||||
try {
|
||||
const data = await this.fetchMetrics(metric);
|
||||
return { metric, data };
|
||||
} catch (error) {
|
||||
console.error(`Error fetching data for metric ${metric}:`, error);
|
||||
return { metric, data: [] };
|
||||
}
|
||||
});
|
||||
|
||||
return Promise.all(promises);
|
||||
}
|
||||
|
||||
clearCache(): void {
|
||||
this.cacheService.clearCache();
|
||||
}
|
||||
}
|
||||
|
|
@ -16,6 +16,9 @@
|
|||
"forceConsistentCasingInFileNames": true,
|
||||
"noImplicitAny": false,
|
||||
"strictBindCallApply": false,
|
||||
"noFallthroughCasesInSwitch": false
|
||||
"noFallthroughCasesInSwitch": false,
|
||||
"types": [
|
||||
"node"
|
||||
]
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue