Compare commits

...

41 Commits
1.0.5 ... main

Author SHA1 Message Date
deployer3000 be67f7dc07 Merge pull request 'rc' (#43) from rc into main
Reviewed-on: http://git.enode/deployer3000/trust-module-backend/pulls/43
2026-01-19 14:34:02 +03:00
Vladislav Drozdov e264d59338 Merge pull request 'swagger' (#42) from swagger into rc
test-org/trust-module-backend/pipeline/pr-main Build started... Details
Reviewed-on: http://git.enode/deployer3000/trust-module-backend/pulls/42
2026-01-19 14:33:12 +03:00
DmitriyA 2beb4e0ee9 Merge branch 'main' of http://git.enode/deployer3000/trust-module-backend into swagger
test-org/trust-module-backend/pipeline/pr-rc Build queued... Details
2026-01-19 06:31:33 -05:00
DmitriyA 054b25484c added env variable 2026-01-19 06:29:59 -05:00
deployer3000 695644e505 Merge pull request 'rc' (#41) from rc into main
Reviewed-on: http://git.enode/deployer3000/trust-module-backend/pulls/41
2025-12-02 12:47:50 +03:00
Vladislav Drozdov d060862e47 Merge pull request 'swagger' (#40) from swagger into rc
test-org/trust-module-backend/pipeline/pr-main Build succeeded
Reviewed-on: http://git.enode/deployer3000/trust-module-backend/pulls/40
2025-12-02 12:45:13 +03:00
deployer3000 61292165d1 Merge branch 'rc' into swagger
test-org/trust-module-backend/pipeline/pr-rc This commit looks good Details
2025-12-02 12:41:32 +03:00
DmitriyA d97a0b95f6 version update
test-org/trust-module-backend/pipeline/pr-rc This commit looks good Details
2025-12-02 04:36:15 -05:00
DmitriyA 6a47ef4601 added formula 2025-10-21 09:14:58 -04:00
deployer3000 05b7609914 Merge pull request 'rc' (#34) from rc into main
Reviewed-on: http://git.enode/deployer3000/trust-module-backend/pulls/34
2025-09-01 16:25:59 +03:00
Vladislav Drozdov 19fae6d7fe Merge pull request 'swagger' (#38) from swagger into rc
Reviewed-on: http://git.enode/deployer3000/trust-module-backend/pulls/38
Reviewed-by: Vladislav Drozdov <ya2@ya.ru>
2025-09-01 16:24:24 +03:00
DmitriyA aaa2482f04 added menu editor 2025-08-28 09:15:42 -04:00
SovietSpiderCat d8a8d6b8e5 added complex variables 2025-08-22 09:58:26 +03:00
SovietSpiderCat c85b89b288 Merge branch 'rc' of http://git.enode/deployer3000/trust-module-backend into swagger 2025-08-20 00:16:32 +03:00
SovietSpiderCat f3ebbc79d5 fixed WS 2025-08-20 00:15:56 +03:00
Vladislav Drozdov 0f4f4bcf15 Merge pull request 'swagger' (#37) from swagger into rc
Reviewed-on: http://git.enode/deployer3000/trust-module-backend/pulls/37
Reviewed-by: Vladislav Drozdov <ya2@ya.ru>
2025-08-14 16:02:14 +03:00
deployer3000 3daef48d4c Merge branch 'rc' into swagger 2025-08-14 16:00:41 +03:00
SovietSpiderCat ad2f740384 change endpoint for proxy 2025-08-14 14:02:42 +03:00
SovietSpiderCat 646597d111 rework ws 2025-08-14 13:51:13 +03:00
Vladislav Drozdov a9650366eb Merge pull request 'added user management' (#35) from swagger into rc
test-org/trust-module-backend/pipeline/pr-main There was a failure building this commit Details
Reviewed-on: http://git.enode/deployer3000/trust-module-backend/pulls/35
Reviewed-by: Vladislav Drozdov <ya2@ya.ru>
2025-08-08 16:03:29 +03:00
SovietSpiderCat 39c7bc8fc0 added user management
test-org/trust-module-backend/pipeline/pr-rc This commit looks good Details
2025-08-08 15:56:47 +03:00
Vladislav Drozdov 86f6614f56 Merge pull request 'swagger' (#33) from swagger into rc
test-org/trust-module-backend/pipeline/pr-main There was a failure building this commit Details
Reviewed-on: http://git.enode/deployer3000/trust-module-backend/pulls/33
Reviewed-by: Vladislav Drozdov <ya2@ya.ru>
2025-08-07 14:58:34 +03:00
DmitriyA 8f538c44a8 optimize subscriptions
test-org/trust-module-backend/pipeline/pr-main Build started... Details
test-org/trust-module-backend/pipeline/pr-rc This commit looks good Details
2025-07-30 18:36:59 -04:00
DmitriyA 926ea01235 added clickhouse service 2025-07-24 08:26:19 -04:00
deployer3000 2067bb9c55 Merge pull request 'rc' (#31) from rc into main
Reviewed-on: http://git.enode/deployer3000/trust-module-backend/pulls/31
2025-07-21 13:20:38 +03:00
Vladislav Drozdov 8466aa1f93 Merge pull request 'adding filter for metrics' (#30) from swagger into rc
test-org/trust-module-backend/pipeline/pr-main Build succeeded
Reviewed-on: http://git.enode/deployer3000/trust-module-backend/pulls/30
Reviewed-by: Vladislav Drozdov <ya2@ya.ru>
2025-07-21 13:15:54 +03:00
DmitriyA ff3bf02d2e Merge branch 'swagger' of http://git.enode/deployer3000/trust-module-backend into swagger
test-org/trust-module-backend/pipeline/pr-rc This commit looks good Details
2025-07-16 10:00:28 -04:00
DmitriyA a76b0b9a86 adding roles 2025-07-16 09:49:52 -04:00
deployer3000 c3b9983b73 Merge branch 'rc' into swagger
test-org/trust-module-backend/pipeline/pr-rc This commit looks good Details
2025-07-14 10:23:24 +03:00
DmitriyA 8a5d530d44 adding filter for metrics
test-org/trust-module-backend/pipeline/pr-rc This commit looks good Details
2025-07-14 03:14:51 -04:00
deployer3000 8672ca7112 Merge pull request 'rc' (#29) from rc into main 2025-06-11 15:19:27 +03:00
Vladislav Drozdov 8abfff99e0 Merge pull request 'swagger' (#28) from swagger into rc
test-org/trust-module-backend/pipeline/pr-main Build succeeded
Reviewed-on: http://git.enode/deployer3000/trust-module-backend/pulls/28
Reviewed-by: Vladislav Drozdov <ya2@ya.ru>
Reviewed-by: YurijO <ya@ya.ru>
2025-06-11 15:18:24 +03:00
DmitriyA 57cf65b9a6 added env
test-org/trust-module-backend/pipeline/pr-rc This commit looks good Details
2025-06-11 08:14:25 -04:00
DmitriyA 4074d45384 added ranges editor
test-org/trust-module-backend/pipeline/pr-rc This commit looks good Details
2025-06-11 07:31:58 -04:00
DmitriyA dadb6f3bcb sidebar menu improvement 2025-06-10 09:28:20 -04:00
deployer3000 16ce1da9a2 Merge pull request 'rc' (#27) from rc into main 2025-06-06 14:45:38 +03:00
Vladislav Drozdov f1527abae6 Merge pull request 'added ranges for charts' (#26) from swagger into rc
test-org/trust-module-backend/pipeline/pr-main Build succeeded
Reviewed-on: http://git.enode/deployer3000/trust-module-backend/pulls/26
Reviewed-by: Vladislav Drozdov <ya2@ya.ru>
Reviewed-by: YurijO <ya@ya.ru>
2025-06-06 14:44:37 +03:00
DmitriyA adf24d9b56 added ranges for charts
test-org/trust-module-backend/pipeline/pr-rc This commit looks good Details
2025-06-05 08:09:44 -04:00
deployer3000 066a1e4eba Merge pull request 'rc' (#25) from rc into main 2025-06-03 13:12:42 +03:00
YurijO 7f6f2171a3 Merge pull request 'websocket fix' (#24) from swagger into rc
test-org/trust-module-backend/pipeline/pr-main Build succeeded
Reviewed-on: http://git.enode/deployer3000/trust-module-backend/pulls/24
Reviewed-by: Vladislav Drozdov <ya2@ya.ru>
Reviewed-by: YurijO <ya@ya.ru>
2025-06-03 13:10:59 +03:00
DmitriyA 5fc70cd610 websocket fix
test-org/trust-module-backend/pipeline/pr-rc This commit looks good Details
2025-06-03 05:57:36 -04:00
39 changed files with 15256 additions and 11771 deletions

44
.env
View File

@ -1,26 +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=192.168.2.39:5173 FRONTEND_URL=http://localhost:5173
#Постгресс # Постгресс
#DB_HOST=192.168.2.37 DB_HOST=192.168.2.37
#DB_PORT=5432 DB_PORT=5432
#DB_USER=trust DB_USER=trust
#DB_PASSWORD=kaiqolzp2a4aH DB_PASSWORD=kaiqolzp2a4aH
#DB_NAME=trust-db DB_NAME=trust-db
#JWT # JWT
#JWT_SECRET=x7F!2p9L#q1$z0*8R5vYgMnBk #JWT_SECRET=x7F!2p9L#q1$z0*8R5vYgMnBk
#JWT_SECRET=x7Fcdp9L#q1$z0*8R5vYgMnBk JWT_SECRET=x7Fcdp9Lq1$z0*8R5vYgMnBk
#COOKIE # COOKIE
# Для production # Для production
#COOKIE_SECURE=true #COOKIE_SECURE=true
#COOKIE_SAME_SITE=strict #COOKIE_SAME_SITE=strict
# Для development # Для development
# COOKIE_SECURE=false COOKIE_SECURE=false
# COOKIE_SAME_SITE=lax 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

View File

@ -10,4 +10,6 @@ COPY . .
ENV NODE_ENV=development ENV NODE_ENV=development
EXPOSE 3000
CMD ["npm", "run", "start:dev"] CMD ["npm", "run", "start:dev"]

BIN
logs.txt Normal file

Binary file not shown.

1988
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -20,31 +20,35 @@
"test:e2e": "jest --config ./test/jest-e2e.json" "test:e2e": "jest --config ./test/jest-e2e.json"
}, },
"dependencies": { "dependencies": {
"@clickhouse/client": "^1.11.2",
"@clickhouse/client-web": "^1.11.2",
"@nestjs/axios": "^4.0.0", "@nestjs/axios": "^4.0.0",
"@nestjs/common": "^11.0.1", "@nestjs/common": "^11.0.1",
"@nestjs/core": "^11.0.1",
"@nestjs/config": "^4.0.0", "@nestjs/config": "^4.0.0",
"@nestjs/platform-express": "^11.0.1", "@nestjs/core": "^11.0.1",
"axios": "^1.7.9",
"reflect-metadata": "^0.2.2",
"dotenv": "^16.3.1",
"rxjs": "^7.8.1",
"@nestjs/typeorm": "^11.0.0",
"pg": "^8.14.1",
"typeorm": "^0.3.21",
"bcrypt": "^5.1.1",
"@types/bcrypt": "^5.0.2",
"socket.io": "^4.8.1",
"@nestjs/websockets": "11.0.12",
"@nestjs/platform-socket.io": "11.0.12",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"cookie-parser": "^1.4.7",
"@types/passport-jwt": "^4.0.1",
"@types/cookie-parser": "^1.4.8",
"@nestjs/jwt": "^11.0.0", "@nestjs/jwt": "^11.0.0",
"@nestjs/passport": "^11.0.5", "@nestjs/passport": "^11.0.5",
"@nestjs/swagger": "11.1.4" "@nestjs/platform-express": "^11.0.1",
"@nestjs/platform-socket.io": "11.0.12",
"@nestjs/swagger": "11.1.4",
"@nestjs/typeorm": "^11.0.0",
"@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",
"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": { "devDependencies": {
"@eslint/eslintrc": "^3.2.0", "@eslint/eslintrc": "^3.2.0",

View File

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

View File

@ -4,7 +4,9 @@ import { HttpModule } from '@nestjs/axios';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { AuthModule } from './auth/auth.module'; import { AuthModule } from './auth/auth.module';
import { MenuModule } from './menu/menu.module'; import { MenuModule } from './menu/menu.module';
import { PrometheusModule } from './prometheus.module'; import { PrometheusModule } from './prometheus/prometheus.module';
import { ClickHouseModule } from './clickhouse/clickhouse.module';
import { ClickHouseController } from './clickhouse/clickhouse.controller';
@Module({ @Module({
imports: [ imports: [
@ -27,6 +29,8 @@ import { PrometheusModule } from './prometheus.module';
AuthModule, AuthModule,
PrometheusModule, PrometheusModule,
MenuModule, MenuModule,
ClickHouseModule,
], ],
controllers: [ClickHouseController],
}) })
export class AppModule {} export class AppModule { }

View File

@ -1,8 +1,9 @@
import { Controller, Post, Get, Body, Res, Req, UnauthorizedException, UseGuards } from '@nestjs/common'; import { Controller, Post, Get, Body, Res, Req, UnauthorizedException, UseGuards, ForbiddenException, Delete, Param } from '@nestjs/common';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { Response, Request } from 'express'; import { Response, Request } from 'express';
import { JwtAuthGuard } from './jwt-auth.guard'; import { JwtAuthGuard } from './jwt-auth.guard';
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
import { User } from './user.entity';
@Controller('auth') @Controller('auth')
export class AuthController { export class AuthController {
@ -20,10 +21,14 @@ export class AuthController {
throw new UnauthorizedException('Пользователь не аутентифицирован'); throw new UnauthorizedException('Пользователь не аутентифицирован');
} }
const user = req.user as { userId: number; username: string; login?: string }; const user = req.user as { userId: number; username: string; login?: string; role?: string };
const userWithoutPassword = { ...user }; const userWithoutPassword = {
id: user.userId,
login: user.login || user.username,
role: user.role
};
this.logger.log(`Аутентифицированный пользователь: ${user.username}`); this.logger.log(`Аутентифицированный пользователь: ${user.username}, роль: ${user.role}`);
return { return {
isAuthenticated: true, isAuthenticated: true,
user: userWithoutPassword user: userWithoutPassword
@ -58,7 +63,8 @@ export class AuthController {
success: true, success: true,
user: { user: {
id: user.id, id: user.id,
login: user.login login: user.login,
role: user.role // Добавляем роль в ответ
}, },
access_token access_token
}; };
@ -72,4 +78,41 @@ export class AuthController {
res.clearCookie('access_token'); res.clearCookie('access_token');
return { success: true }; 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' };
}
} }

View File

@ -1,8 +1,9 @@
import { Injectable } from '@nestjs/common'; import { Injectable, ForbiddenException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { User } from './user.entity'; import { User } from './user.entity';
import * as bcrypt from 'bcrypt';
@Injectable() @Injectable()
export class AuthService { export class AuthService {
@ -12,23 +13,59 @@ export class AuthService {
private jwtService: JwtService, private jwtService: JwtService,
) { } ) { }
async validateUser(login: string, password: string): Promise<any> { async validateUser(login: string, password: string): Promise<any> {
const user = await this.usersRepository.findOne({ where: { login } }); const user = await this.usersRepository.findOne({ where: { login } });
if (user && user.password === password) { if (user && user.password === password) {
const { password, ...result } = user; const { password, ...result } = user;
return result; return {
...result,
role: user.role
};
} }
return null; return null;
} }
async login(user: any) { async login(user: any) {
const payload = { const payload = {
username: user.login, username: user.login,
sub: user.id sub: user.id,
role: user.role
}; };
return { return {
access_token: this.jwtService.sign(payload), 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';
}
} }

View File

@ -22,7 +22,8 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
return { return {
userId: payload.sub, userId: payload.sub,
username: payload.username, username: payload.username,
login: payload.username login: payload.username,
role: payload.role
}; };
} }
} }

View File

@ -10,4 +10,7 @@ export class User {
@Column() @Column()
password: string; password: string;
@Column({ default: 'user' })
role: 'user' | 'admin';
} }

View File

@ -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}`);
}
}
}

View File

@ -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 { }

View File

@ -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 [];
}
});
}
}

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -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
);
}
}
}

View File

@ -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;
}

View File

@ -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);
}
}
}

View File

@ -1,4 +1,4 @@
import { Controller, Get, Param, Post, Body, Put } from '@nestjs/common'; import { Controller, Get, Post, Put, Body, Param, Headers, HttpException, HttpStatus, Delete } from '@nestjs/common';
import { MenuService } from './menu.service'; import { MenuService } from './menu.service';
import { MenuItem } from './menu.interface'; import { MenuItem } from './menu.interface';
@ -6,32 +6,110 @@ import { MenuItem } from './menu.interface';
export class MenuController { export class MenuController {
constructor(private readonly menuService: MenuService) { } constructor(private readonly menuService: MenuService) { }
@Get()
async getMenu(): Promise<MenuItem> {
return this.menuService.getFullMenu();
}
@Post('save')
async saveMenu() {
await this.menuService.saveMenuToFile();
return { status: 'saved' };
}
@Get('full') @Get('full')
async getFullMenu(): Promise<MenuItem> { async getFullMenu(@Headers('if-modified-since') ifModifiedSince?: string) {
return this.menuService.getFullMenu(); 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);
} }
@Post('overrides') return result.menu;
async saveOverrides(@Body() data: { overrides: Partial<MenuItem>[] }) { } catch (error) {
return this.menuService.saveOverrides(data.overrides); 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') @Put(':id')
async updateMenuItem( async updateMenuItem(
@Param('id') id: string, @Param('id') id: string,
@Body() update: Partial<MenuItem> @Body() update: Partial<MenuItem>
): Promise<MenuItem> { ) {
return this.menuService.updateMenuItem(id, update); try {
const updatedItem = await this.menuService.updateMenuItem(id, update);
return updatedItem;
} catch (error) {
throw new HttpException(
error.message || 'Failed to update menu item',
HttpStatus.INTERNAL_SERVER_ERROR
);
}
}
@Delete('items/:id')
async deleteMenuItem(@Param('id') id: string) {
console.log(`DELETE /menu/items/${id} requested`);
try {
await this.menuService.hideMenuItem(id);
console.log(`Item ${id} hidden successfully`);
return { success: true, message: 'Item hidden successfully' };
} catch (error) {
console.error(`Error hiding item ${id}:`, error);
throw new HttpException(
error.message || 'Failed to hide menu item',
HttpStatus.INTERNAL_SERVER_ERROR
);
}
}
@Post('invalidate-cache')
async invalidateCache() {
try {
this.menuService.invalidateCache();
return { success: true, message: 'Cache invalidated successfully' };
} catch (error) {
throw new HttpException(
error.message || 'Failed to invalidate cache',
HttpStatus.INTERNAL_SERVER_ERROR
);
}
}
@Get('debug/overrides')
async debugOverrides() {
try {
return { status: 'Debug endpoint not implemented' };
} catch (error) {
throw new HttpException(
error.message || 'Debug failed',
HttpStatus.INTERNAL_SERVER_ERROR
);
}
} }
} }

View File

@ -6,4 +6,10 @@ export interface MenuItem {
filters?: Record<string, string>; filters?: Record<string, string>;
isDynamic?: boolean; isDynamic?: boolean;
templateId?: string; templateId?: string;
} ranges?: Array<{
min: number;
max: number;
status: number;
}>;
hidden?: boolean;
}

0
src/menu/menu.json Normal file
View File

View File

@ -1,11 +1,19 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { MenuController } from './menu.controller'; import { MenuController } from './menu.controller';
import { HttpModule } from '@nestjs/axios';
import { MenuService } from './menu.service'; import { MenuService } from './menu.service';
import { PrometheusModule } from '../prometheus.module'; // Импортируем PrometheusModule 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({ @Module({
imports: [PrometheusModule], // Добавляем в imports imports: [PrometheusModule, HttpModule],
controllers: [MenuController], controllers: [MenuController, RangeController, FormulaController, EnrichedFormulaController],
providers: [MenuService] providers: [MenuService, RangeService, FormulaService, FormulaEnrichmentService],
exports: [FormulaEnrichmentService]
}) })
export class MenuModule {} export class MenuModule { }

View File

@ -1,55 +1,121 @@
import { Injectable } from '@nestjs/common'; import { Injectable, HttpException, HttpStatus } from '@nestjs/common';
import { PrometheusService } from '../prometheus.service'; import { PrometheusService } from '../prometheus/prometheus.service';
import { MenuItem } from './menu.interface'; import { MenuItem } from './menu.interface';
import * as fs from 'fs/promises'; import * as fs from 'fs/promises';
import * as path from 'path'; import * as path from 'path';
import { RangeService } from './range.service';
@Injectable() @Injectable()
export class MenuService { export class MenuService {
constructor(private readonly prometheusService: PrometheusService) { } private menuCache: MenuItem | null = null;
private lastModified: Date | null = null;
private cacheInitialized = false;
private userOverrides: Map<string, Partial<MenuItem>> = new Map();
private readonly menuOverridesPath = path.join(process.cwd(), 'data', 'menu.json'); constructor(
private readonly prometheusService: PrometheusService,
private readonly rangeService: RangeService
) { }
async saveMenuToFile(): Promise<void> {
const menu = await this.getFullMenu(); private readonly userOverridesPath = path.join(process.cwd(), 'data', 'user_menu_overrides.json');
await fs.mkdir(path.dirname(this.menuOverridesPath), { recursive: true });
await fs.writeFile(this.menuOverridesPath, JSON.stringify(menu, null, 2), 'utf-8');
async getFullMenuWithCache(ifModifiedSince?: string): Promise<{ menu: MenuItem; fresh: boolean }> {
if (this.menuCache && this.lastModified && (!ifModifiedSince || new Date(ifModifiedSince) >= this.lastModified)) {
return { menu: this.menuCache, fresh: false };
} }
async getFullMenu(): Promise<MenuItem> { await this.loadUserOverrides();
const dynamicItems = await this.generateDynamicItems();
const baseMenu = this.injectDynamicItems(this.getStaticStructure(), dynamicItems); const dynamicItemsPromise = this.generateDynamicItems();
const overrides = await this.loadOverrides(); const baseMenu = await this.injectDynamicItems(this.getStaticStructure(), dynamicItemsPromise);
return this.applyOverrides(baseMenu, overrides);
const freshMenu = this.applyUserOverrides(baseMenu);
this.menuCache = freshMenu;
this.lastModified = new Date();
this.cacheInitialized = true;
return { menu: freshMenu, fresh: true };
} }
private applyOverrides(menu: MenuItem, overrides: Partial<MenuItem>[]): MenuItem { async checkForUpdates(ifModifiedSince: string): Promise<boolean> {
const overrideMap = new Map(overrides.map(o => [o.id, o])); if (!this.cacheInitialized) {
await this.getFullMenuWithCache();
}
return !this.lastModified || new Date(ifModifiedSince) < this.lastModified;
}
const apply = (item: MenuItem): MenuItem => { async hideMenuItem(id: string): Promise<void> {
const override = overrideMap.get(item.id); this.userOverrides.set(id, { id, hidden: true });
const updated = override ? { ...item, ...override } : item; 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) { if (updated.items) {
updated.items = updated.items.map(apply); const processedItems = updated.items
.map(apply)
.filter((item): item is MenuItem => item !== null);
updated.items = processedItems.length > 0 ? processedItems : undefined;
} }
return updated; return updated;
}; };
return apply(menu); const result = apply(menu);
return result || { title: menu.title, id: menu.id, items: [] };
} }
private async loadOverrides(): Promise<Partial<MenuItem>[]> { private async loadUserOverrides(): Promise<void> {
try { try {
const content = await fs.readFile(this.menuOverridesPath, 'utf-8'); const content = await fs.readFile(this.userOverridesPath, 'utf-8');
const parsed = JSON.parse(content); const parsed = JSON.parse(content);
return parsed.overrides || [];
this.userOverrides = new Map(
(parsed.overrides || []).map(o => [o.id, o])
);
} catch (e) { } catch (e) {
return []; // если файл не существует this.userOverrides = new Map();
await this.saveUserOverrides(); // Создаем файл с пустыми данными
} }
} }
private async saveUserOverrides(): Promise<void> {
try {
await fs.mkdir(path.dirname(this.userOverridesPath), { recursive: true });
const overridesArray = Array.from(this.userOverrides.values());
await fs.writeFile(
this.userOverridesPath,
JSON.stringify({ overrides: overridesArray }, null, 2),
'utf-8'
);
} catch (error) {
console.error('Error saving user overrides:', error);
throw new HttpException(
'Failed to save user preferences',
HttpStatus.INTERNAL_SERVER_ERROR
);
}
}
private getStaticStructure(): MenuItem { private getStaticStructure(): MenuItem {
return { return {
title: "ЗВКС", title: "ЗВКС",
@ -73,7 +139,6 @@ export class MenuService {
private async generateDynamicItems(): Promise<MenuItem[]> { private async generateDynamicItems(): Promise<MenuItem[]> {
const metricNames = await this.prometheusService.fetchAllMetrics(); const metricNames = await this.prometheusService.fetchAllMetrics();
// Получаем все серии для каждой метрики
const allSeries = ( const allSeries = (
await Promise.all( await Promise.all(
metricNames.map(async name => { metricNames.map(async name => {
@ -86,9 +151,7 @@ export class MenuService {
) )
).flat(); ).flat();
// Загружаем мета-информацию по каждой метрике const metadataMap = new Map<string, string>();
const metadataMap = new Map<string, string>(); // metric -> help
await Promise.all( await Promise.all(
metricNames.map(async metric => { metricNames.map(async metric => {
try { try {
@ -126,18 +189,33 @@ export class MenuService {
const filteredSeries = allSeries.filter(({ labels }) => { const filteredSeries = allSeries.filter(({ labels }) => {
const device = labels.device; const device = labels.device;
const instance = labels.instance; const instance = labels.instance;
return (!device || !isGarbageDevice(device)) && return (!device || !isGarbageDevice(device)) &&
(!instance || !isGarbageInstance(instance)); (!instance || !isGarbageInstance(instance));
}); });
const devices = this.extractUniqueEntities(filteredSeries, 'device');
return devices.map(device => ({ 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}`, id: `device_${device}`,
title: `Graviton S2082I (${device})`, title: deviceName,
items: this.generateModuleItems(device, allSeries, metadataMap), items: moduleItems,
isDynamic: true isDynamic: true
})); };
} }
private extractUniqueEntities(metrics: any[], field: string): string[] { private extractUniqueEntities(metrics: any[], field: string): string[] {
@ -150,78 +228,278 @@ export class MenuService {
return Array.from(entities); return Array.from(entities);
} }
private generateModuleItems( 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, device: string,
seriesData: { metric: string; labels: Record<string, string> }[], seriesData: { metric: string; labels: Record<string, string> }[],
metadataMap: Map<string, string> metadataMap: Map<string, string>
): MenuItem[] { ): Promise<MenuItem[]> {
const modules = new Set<string>(); const modules = new Map<string, string>();
const specialFolders = new Map<string, Map<string, string>>();
seriesData.forEach(({ labels }) => { seriesData.forEach(({ labels }) => {
if (labels.device === device && labels.source_id) { if (labels.device === device && labels.source_id) {
modules.add(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);
}
} }
}); });
return Array.from(modules).map(module => ({ const moduleItems = Array.from(modules.entries()).map(
id: `module_${device}_${module}`, async ([sourceId, displayName]) => ({
title: `Module ${module.replace('module$', '')}`, id: `module_${device}_${this.normalizeIdPart(sourceId)}`,
items: this.generateMetricItems(device, module, seriesData, metadataMap), title: displayName,
items: await this.generateMetricItems(device, sourceId, seriesData, metadataMap),
isDynamic: true 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 generateMetricItems( private normalizeSourceId(raw: string): string {
return raw.split(',').map(s => s.trim()).filter(Boolean).join(', ');
}
private async generateMetricItems(
device: string, device: string,
module: string, module: string,
seriesData: { metric: string; labels: Record<string, string> }[], seriesData: { metric: string; labels: Record<string, string> }[],
metadataMap: Map<string, string> metadataMap: Map<string, string>
): MenuItem[] { ): Promise<MenuItem[]> {
const filtered = seriesData.filter( const ranges = await this.rangeService.getRanges();
({ labels }) => labels.device === device && labels.source_id === module
const normModule = this.normalizeSourceId(module);
const isPlainModule = !normModule.includes(',');
let filtered = seriesData.filter(({ labels }) =>
labels.device === device &&
this.normalizeSourceId(labels.source_id || '') === normModule
); );
if (isPlainModule) {
const base = normModule;
const shadowSuffixes = ['integration', 'complex'];
filtered = filtered.filter(entry => {
return !shadowSuffixes.some(suffix =>
seriesData.some(s =>
s.metric === entry.metric &&
s.labels.device === device &&
this.normalizeSourceId(s.labels.source_id || '') === `${base}, ${suffix}`
)
);
});
}
const uniqueMetrics = new Set(filtered.map(entry => entry.metric)); const uniqueMetrics = new Set(filtered.map(entry => entry.metric));
const safeDevice = this.normalizeIdPart(device);
const safeModule = this.normalizeIdPart(normModule);
return Array.from(uniqueMetrics).map(metric => { return Array.from(uniqueMetrics).map(metric => {
const description = metadataMap.get(metric) || metric; const description = metadataMap.get(metric) || metric;
const safeMetric = this.normalizeIdPart(metric);
const metricRanges = ranges[description] || [];
return { return {
id: `metric_${device}_${module}_${metric}`, id: `metric_${safeDevice}_${safeModule}_${safeMetric}`,
title: description, title: description,
metric, metric,
filters: { filters: {
device, device,
source_id: module source_id: normModule
}, },
isDynamic: true ranges: metricRanges,
isDynamic: true,
meta: {
originalDevice: device,
originalModule: normModule
}
}; };
}); });
} }
private injectDynamicItems(menu: MenuItem, dynamicItems: MenuItem[]): MenuItem { private async injectDynamicItems(
menu: MenuItem,
dynamicItemsPromise: Promise<MenuItem[]>
): Promise<MenuItem> {
const dynamicItems = await dynamicItemsPromise;
if (menu.id === 'media_servers') { if (menu.id === 'media_servers') {
return { ...menu, items: dynamicItems }; return { ...menu, items: dynamicItems };
} }
return { if (menu.items) {
...menu, const updatedItems = await Promise.all(
items: menu.items?.map(item => this.injectDynamicItems(item, dynamicItems)) || [] menu.items.map(item => this.injectDynamicItems(item, dynamicItemsPromise))
}; );
return { ...menu, items: updatedItems };
}
return menu;
} }
async updateMenuItem(id: string, update: Partial<MenuItem>): Promise<MenuItem> { async updateMenuItem(id: string, update: Partial<MenuItem>): Promise<MenuItem> {
const fullMenu = await this.getFullMenu(); const existing = this.userOverrides.get(id) || { id };
const item = this.findMenuItem(fullMenu, id); this.userOverrides.set(id, { ...existing, ...update });
if (!item) throw new Error('Menu item not found'); await this.saveUserOverrides();
Object.assign(item, update); this.invalidateCache();
return item; const { menu } = await this.getFullMenuWithCache();
const updated = this.findMenuItem(menu, id);
if (!updated) {
throw new HttpException('Updated item not found', HttpStatus.NOT_FOUND);
} }
async saveOverrides(overrides: Partial<MenuItem>[]): Promise<void> { return updated;
await fs.writeFile(this.menuOverridesPath, JSON.stringify({ overrides }, null, 2), 'utf-8'); }
invalidateCache(): void {
this.menuCache = null;
this.lastModified = new Date();
} }
private findMenuItem(menu: MenuItem, id: string): MenuItem | null { private findMenuItem(menu: MenuItem, id: string): MenuItem | null {

View File

@ -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);
}
}
}

78
src/menu/range.service.ts Normal file
View File

@ -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');
}
}
}

View File

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

View File

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

View File

@ -1,238 +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';
import { MenuItem } from './menu/menu.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[]> {
try {
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 => ({
__name__: entry.metric.__name__ || metric,
device: entry.metric.device,
instance: entry.metric.instance,
job: entry.metric.job,
source_id: entry.metric.source_id,
status: entry.metric.status || '0',
timestamp: entry.value[0] * 1000,
value: parseFloat(entry.value[1]),
type: metricType || 'gauge',
description: metricDescription,
...entry.metric
}));
} catch (error) {
console.error(`Error fetching metrics for ${metric}:`, error);
throw error;
}
}
async fetchMetricsWithFilters(metric: string, filters: Record<string, string>): Promise<PrometheusMetric[]> {
try {
const query = this.buildFilteredQuery(metric, filters);
const response = await lastValueFrom(
this.httpService.get(`${this.prometheusUrl}/query`, {
params: { query }
})
);
const metricType = await this.fetchMetricType(metric);
const metricDescription = await this.fetchMetricDescription(metric);
return response.data.data.result.map((entry): PrometheusMetric => ({
__name__: entry.metric.__name__ || metric,
device: entry.metric.device,
instance: entry.metric.instance,
job: entry.metric.job,
source_id: entry.metric.source_id,
status: entry.metric.status || '0',
timestamp: entry.value[0] * 1000,
value: parseFloat(entry.value[1]),
type: metricType || 'gauge',
description: metricDescription,
...entry.metric
}));
} catch (error) {
console.error(`Error fetching metrics with filters for ${metric}:`, error);
throw error;
}
}
private buildFilteredQuery(metric: string, filters: Record<string, string>): string {
const filterParts = Object.entries(filters)
.filter(([_, value]) => value !== undefined && value !== null && value !== "")
.map(([key, value]) => {
if (key === 'source_id' && !value.startsWith('module$')) {
return `${key}="module$${value}"`;
}
return `${key}="${value}"`;
});
return filterParts.length > 0
? `${metric}{${filterParts.join(',')}}`
: metric;
}
async fetchMetricsRange(metric: string, start: number, end: number, step: number, filters: Record<string, string> = {}): Promise<PrometheusMetric[]> {
const query = this.buildFilteredQuery(metric, {
...filters,
instance: '192.168.2.34:9050' // Явно фильтруем по нужному instance
});
try {
const response = await lastValueFrom(
this.httpService.get(`${this.prometheusUrl}/query_range`, {
params: {
query,
start,
end,
step: step.toString()
},
})
);
const metricType = await this.fetchMetricType(metric);
const metricDescription = await this.fetchMetricDescription(metric);
return response.data.data.result.flatMap((entry) =>
entry.values.map((value): PrometheusMetric => ({
__name__: entry.metric.__name__ || metric,
device: entry.metric.device,
instance: entry.metric.instance,
job: entry.metric.job,
source_id: entry.metric.source_id,
status: entry.metric.status || '0',
timestamp: value[0] * 1000,
value: parseFloat(value[1]),
type: metricType || 'gauge',
description: metricDescription,
...entry.metric
}))
);
} catch (error) {
console.error('Error in fetchMetricsRange:', {
error: error.response?.data || error.message,
query,
filters
});
throw error;
}
}
async getMetricsForMenuItem(menuItem: MenuItem): Promise<PrometheusMetric[]> {
if (!menuItem.metric || !menuItem.filters) {
throw new Error('MenuItem is not a metric item');
}
return this.fetchMetricsWithFilters(menuItem.metric, menuItem.filters);
}
// ✅ Новый метод: получает базовое описание метрики (help, type)
async fetchMetricMetadata(metric: string): Promise<{
name: string;
help?: string;
type?: string;
}> {
try {
const response = await lastValueFrom(
this.httpService.get(`${this.prometheusUrl}/metadata`, {
params: { metric }
})
);
const data = response.data?.data?.[metric]?.[0];
return {
name: metric,
help: data?.help,
type: data?.type
};
} catch (error) {
console.error(`Error fetching metadata for ${metric}:`, error);
return {
name: metric
};
}
}
// ✅ Новый метод: получает ВСЕ серии метрики (все комбинации label-ов)
async fetchMetricSeries(metric: string): Promise<Record<string, string>[]> {
try {
const response = await lastValueFrom(
this.httpService.get(`${this.prometheusUrl}/series`, {
params: { 'match[]': metric }
})
);
return response.data.data || [];
} catch (error) {
console.error(`Error fetching series for ${metric}:`, error);
return [];
}
}
async fetchAllMetrics(): Promise<string[]> {
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);
}
}

5
src/prometheus/index.ts Normal file
View File

@ -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';

View File

@ -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
});
}
}

View File

@ -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();
}
}

View File

@ -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}`;
}
}

View File

@ -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 { }

View File

@ -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();
}
}