Merge pull request 'Убран лишний код. Скорректированы docker и package файлы. Переработано взаимодействие с бд - упрощена логика.' (#1) from dev into main
Reviewed-on: #1 Reviewed-by: Никита Бизяев <cap7goo@gmail.com>
This commit was merged in pull request #1.
This commit is contained in:
4
.dockerignore
Normal file
4
.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
10
.env.example
10
.env.example
@@ -5,15 +5,5 @@ CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173
|
|||||||
# Optional. When set, POST /ingest requires x-api-key: <value>.
|
# Optional. When set, POST /ingest requires x-api-key: <value>.
|
||||||
INGEST_API_KEY=
|
INGEST_API_KEY=
|
||||||
|
|
||||||
# Pool sizing for node-postgres.
|
|
||||||
PG_POOL_MAX=20
|
|
||||||
|
|
||||||
# Maximum points accepted by one POST /ingest request.
|
|
||||||
INGEST_MAX_BATCH_SIZE=10000
|
|
||||||
|
|
||||||
# Poll interval for GET /current/events SSE snapshots.
|
# Poll interval for GET /current/events SSE snapshots.
|
||||||
CURRENT_EVENTS_POLL_MS=1000
|
CURRENT_EVENTS_POLL_MS=1000
|
||||||
|
|
||||||
# Graph API defaults.
|
|
||||||
HISTORY_TARGET_POINTS=2000
|
|
||||||
HISTORY_MAX_TARGET_POINTS=5000
|
|
||||||
|
|||||||
@@ -2,8 +2,4 @@ PORT=3101
|
|||||||
DATABASE_URL=postgres://greact:pG3526l4@194.36.208.86:5433/cloud
|
DATABASE_URL=postgres://greact:pG3526l4@194.36.208.86:5433/cloud
|
||||||
CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173
|
CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173
|
||||||
INGEST_API_KEY=
|
INGEST_API_KEY=
|
||||||
PG_POOL_MAX=20
|
|
||||||
INGEST_MAX_BATCH_SIZE=10000
|
|
||||||
CURRENT_EVENTS_POLL_MS=1000
|
CURRENT_EVENTS_POLL_MS=1000
|
||||||
HISTORY_TARGET_POINTS=2000
|
|
||||||
HISTORY_MAX_TARGET_POINTS=5000
|
|
||||||
|
|||||||
18
Dockerfile
18
Dockerfile
@@ -1,18 +1,14 @@
|
|||||||
FROM node:22-alpine AS deps
|
FROM node:22-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
FROM node:22-alpine AS build
|
COPY tsconfig*.json ./
|
||||||
WORKDIR /app
|
COPY src ./src
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
|
||||||
COPY . .
|
RUN npm run build && npm prune --omit=dev
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
FROM node:22-alpine AS runtime
|
|
||||||
WORKDIR /app
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
COPY package*.json ./
|
|
||||||
RUN npm ci --omit=dev
|
|
||||||
COPY --from=build /app/dist ./dist
|
|
||||||
CMD ["node", "dist/main.js"]
|
CMD ["node", "dist/main.js"]
|
||||||
|
|||||||
14
README.md
14
README.md
@@ -7,7 +7,7 @@ Lightweight Drill Cloud API for an existing PostgreSQL/TimescaleDB database.
|
|||||||
- No migrations.
|
- No migrations.
|
||||||
- No `tag-translation` module.
|
- No `tag-translation` module.
|
||||||
- No alarm log writes.
|
- No alarm log writes.
|
||||||
- No automatic `current`, `edge`, or `tag` upserts during ingest.
|
- No automatic `edge` or `tag` upserts during ingest.
|
||||||
|
|
||||||
## Database
|
## Database
|
||||||
|
|
||||||
@@ -36,24 +36,22 @@ Historical chart ranges use continuous aggregates:
|
|||||||
- `POST /ingest`
|
- `POST /ingest`
|
||||||
- `POST /ingest/:edge`
|
- `POST /ingest/:edge`
|
||||||
|
|
||||||
`POST /ingest` accepts an array of points:
|
`POST /ingest` accepts one point:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
[
|
{
|
||||||
{
|
|
||||||
"edge": "edge5",
|
"edge": "edge5",
|
||||||
"tag": "hook_weight_1",
|
"tag": "hook_weight_1",
|
||||||
"time": "2026-06-19T00:00:00Z",
|
"timestamp": "2026-06-19T00:00:00Z",
|
||||||
"value": 12.34
|
"value": 12.34
|
||||||
}
|
}
|
||||||
]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`POST /ingest/:edge` accepts a compact edge payload:
|
`POST /ingest/:edge` accepts a compact edge payload:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"time": "2026-06-19T00:00:00Z",
|
"timestamp": "2026-06-19T00:00:00Z",
|
||||||
"values": {
|
"values": {
|
||||||
"hook_weight_1": 12.34,
|
"hook_weight_1": 12.34,
|
||||||
"rotary_rpm": 80
|
"rotary_rpm": 80
|
||||||
|
|||||||
@@ -5,17 +5,13 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
PORT: ${PORT:-3101}
|
PORT: ${PORT}
|
||||||
DATABASE_URL: ${DATABASE_URL}
|
DATABASE_URL: ${DATABASE_URL}
|
||||||
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-}
|
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS}
|
||||||
INGEST_API_KEY: ${INGEST_API_KEY:-}
|
INGEST_API_KEY: ${INGEST_API_KEY}
|
||||||
PG_POOL_MAX: ${PG_POOL_MAX:-20}
|
CURRENT_EVENTS_POLL_MS: ${CURRENT_EVENTS_POLL_MS}
|
||||||
INGEST_MAX_BATCH_SIZE: ${INGEST_MAX_BATCH_SIZE:-10000}
|
expose:
|
||||||
CURRENT_EVENTS_POLL_MS: ${CURRENT_EVENTS_POLL_MS:-1000}
|
- "3101"
|
||||||
HISTORY_TARGET_POINTS: ${HISTORY_TARGET_POINTS:-2000}
|
|
||||||
HISTORY_MAX_TARGET_POINTS: ${HISTORY_MAX_TARGET_POINTS:-5000}
|
|
||||||
ports:
|
|
||||||
- "${PORT:-3101}:${PORT:-3101}"
|
|
||||||
networks:
|
networks:
|
||||||
- proxy
|
- proxy
|
||||||
|
|
||||||
|
|||||||
56
package-lock.json
generated
56
package-lock.json
generated
@@ -10,7 +10,6 @@
|
|||||||
"license": "PERSONAL",
|
"license": "PERSONAL",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/common": "^11.0.1",
|
"@nestjs/common": "^11.0.1",
|
||||||
"@nestjs/config": "^4.0.2",
|
|
||||||
"@nestjs/core": "^11.0.1",
|
"@nestjs/core": "^11.0.1",
|
||||||
"@nestjs/platform-express": "^11.0.1",
|
"@nestjs/platform-express": "^11.0.1",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
@@ -2359,21 +2358,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@nestjs/config": {
|
|
||||||
"version": "4.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@nestjs/config/-/config-4.0.4.tgz",
|
|
||||||
"integrity": "sha512-CJPjNitr0bAufSEnRe2N+JbnVmMmDoo6hvKCPzXgZoGwJSmp/dZPk9f/RMbuD/+Q1ZJPjwsRpq0vxna++Knwow==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"dotenv": "17.4.1",
|
|
||||||
"dotenv-expand": "12.0.3",
|
|
||||||
"lodash": "4.18.1"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@nestjs/common": "^10.0.0 || ^11.0.0",
|
|
||||||
"rxjs": "^7.1.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@nestjs/core": {
|
"node_modules/@nestjs/core": {
|
||||||
"version": "11.1.26",
|
"version": "11.1.26",
|
||||||
"resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.26.tgz",
|
"resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.26.tgz",
|
||||||
@@ -4938,45 +4922,6 @@
|
|||||||
"node": ">=0.3.1"
|
"node": ">=0.3.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/dotenv": {
|
|
||||||
"version": "17.4.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.1.tgz",
|
|
||||||
"integrity": "sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw==",
|
|
||||||
"license": "BSD-2-Clause",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://dotenvx.com"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/dotenv-expand": {
|
|
||||||
"version": "12.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-12.0.3.tgz",
|
|
||||||
"integrity": "sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==",
|
|
||||||
"license": "BSD-2-Clause",
|
|
||||||
"dependencies": {
|
|
||||||
"dotenv": "^16.4.5"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://dotenvx.com"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/dotenv-expand/node_modules/dotenv": {
|
|
||||||
"version": "16.6.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
|
||||||
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
|
|
||||||
"license": "BSD-2-Clause",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://dotenvx.com"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/dunder-proto": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
@@ -7375,6 +7320,7 @@
|
|||||||
"version": "4.18.1",
|
"version": "4.18.1",
|
||||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/lodash.memoize": {
|
"node_modules/lodash.memoize": {
|
||||||
|
|||||||
13
package.json
13
package.json
@@ -6,15 +6,14 @@
|
|||||||
"license": "PERSONAL",
|
"license": "PERSONAL",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "nest build",
|
"build": "nest build",
|
||||||
"start": "nest start",
|
"start": "nest start --exec \"node --env-file=.env\"",
|
||||||
"start:dev": "nest start --watch",
|
"start:dev": "nest start --watch --exec \"node --env-file=.env\"",
|
||||||
"start:prod": "node dist/main.js",
|
"start:prod": "node dist/main.js",
|
||||||
"lint": "eslint \"src/**/*.ts\"",
|
"lint": "eslint \"src/**/*.ts\"",
|
||||||
"test": "jest --runInBand"
|
"test": "jest --runInBand"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/common": "^11.0.1",
|
"@nestjs/common": "^11.0.1",
|
||||||
"@nestjs/config": "^4.0.2",
|
|
||||||
"@nestjs/core": "^11.0.1",
|
"@nestjs/core": "^11.0.1",
|
||||||
"@nestjs/platform-express": "^11.0.1",
|
"@nestjs/platform-express": "^11.0.1",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
@@ -25,22 +24,22 @@
|
|||||||
"rxjs": "^7.8.1"
|
"rxjs": "^7.8.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.18.0",
|
||||||
"@nestjs/cli": "^11.0.0",
|
"@nestjs/cli": "^11.0.0",
|
||||||
"@types/compression": "^1.8.1",
|
"@types/compression": "^1.8.1",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/node": "^22.10.7",
|
"@types/node": "^22.10.7",
|
||||||
"@types/pg": "^8.11.10",
|
"@types/pg": "^8.11.10",
|
||||||
|
"eslint": "^9.18.0",
|
||||||
|
"globals": "^16.0.0",
|
||||||
"jest": "^30.0.0",
|
"jest": "^30.0.0",
|
||||||
"ts-jest": "^29.2.5",
|
"ts-jest": "^29.2.5",
|
||||||
"ts-loader": "^9.5.2",
|
"ts-loader": "^9.5.2",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"tsconfig-paths": "^4.2.0",
|
"tsconfig-paths": "^4.2.0",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^5.7.3",
|
||||||
"typescript-eslint": "^8.20.0",
|
"typescript-eslint": "^8.20.0"
|
||||||
"@eslint/js": "^9.18.0",
|
|
||||||
"eslint": "^9.18.0",
|
|
||||||
"globals": "^16.0.0"
|
|
||||||
},
|
},
|
||||||
"jest": {
|
"jest": {
|
||||||
"moduleFileExtensions": [
|
"moduleFileExtensions": [
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
|
||||||
import { CurrentModule } from './current/current.module';
|
import { CurrentModule } from './current/current.module';
|
||||||
import { DbModule } from './db/db.module';
|
import { DbModule } from './db/db.module';
|
||||||
import { EdgeModule } from './edge/edge.module';
|
import { EdgeModule } from './edge/edge.module';
|
||||||
@@ -10,7 +9,6 @@ import { TagModule } from './tag/tag.module';
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
ConfigModule.forRoot({ isGlobal: true }),
|
|
||||||
DbModule,
|
DbModule,
|
||||||
IngestModule,
|
IngestModule,
|
||||||
EdgeModule,
|
EdgeModule,
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
|
|
||||||
/** Читает числовой параметр конфига и использует резервное значение, если он пустой или неверный. */
|
|
||||||
export function getNumberConfig(
|
|
||||||
config: ConfigService,
|
|
||||||
key: string,
|
|
||||||
fallback: number,
|
|
||||||
): number {
|
|
||||||
const value = config.get<string>(key);
|
|
||||||
if (value === undefined || value === '') {
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = Number(value);
|
|
||||||
return Number.isFinite(parsed) ? parsed : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Читает числовой параметр конфига и округляет вниз для лимитов и размеров пулов. */
|
|
||||||
export function getIntegerConfig(
|
|
||||||
config: ConfigService,
|
|
||||||
key: string,
|
|
||||||
fallback: number,
|
|
||||||
): number {
|
|
||||||
return Math.floor(getNumberConfig(config, key, fallback));
|
|
||||||
}
|
|
||||||
@@ -1,14 +1,11 @@
|
|||||||
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
|
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import type { Request } from 'express';
|
import type { Request } from 'express';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class IngestApiKeyGuard implements CanActivate {
|
export class IngestApiKeyGuard implements CanActivate {
|
||||||
constructor(private readonly config: ConfigService) {}
|
|
||||||
|
|
||||||
/** По умолчанию открывает ingest, но проверяет x-api-key, если задан INGEST_API_KEY. */
|
/** По умолчанию открывает ingest, но проверяет x-api-key, если задан INGEST_API_KEY. */
|
||||||
canActivate(context: ExecutionContext): boolean {
|
canActivate(context: ExecutionContext): boolean {
|
||||||
const expectedKey = this.config.get<string>('INGEST_API_KEY');
|
const expectedKey = process.env.INGEST_API_KEY;
|
||||||
if (!expectedKey) {
|
if (!expectedKey) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
import { BadRequestException } from '@nestjs/common';
|
|
||||||
|
|
||||||
// Единая нормализация обязательных строк: trim + понятная 400-ошибка для API.
|
|
||||||
/** Обрезает обязательное текстовое поле и возвращает 400, если оно стало пустым. */
|
|
||||||
export function normalizeRequiredText(value: string, fieldName: string): string {
|
|
||||||
const normalized = value.trim();
|
|
||||||
if (!normalized) {
|
|
||||||
throw new BadRequestException(`${fieldName} is required.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Обрезает необязательный query-текст и превращает пустые строки в undefined. */
|
|
||||||
export function normalizeOptionalText(value: unknown): string | undefined {
|
|
||||||
if (value === undefined || value === null) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalized = String(value).trim();
|
|
||||||
return normalized || undefined;
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
// Поддерживает оба формата: ?tags=a,b и ?tags=a&tags=b; дубликаты убираются.
|
|
||||||
/** Приводит повторяющиеся или разделенные запятыми query-параметры к чистому массиву строк. */
|
|
||||||
export function parseCommaSeparatedList(value: unknown): string[] | undefined {
|
|
||||||
if (value == null || value === '') {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rawItems = Array.isArray(value)
|
|
||||||
? value.flatMap((item) => String(item).split(','))
|
|
||||||
: String(value).split(',');
|
|
||||||
|
|
||||||
const items = rawItems.map((item) => item.trim()).filter(Boolean);
|
|
||||||
return items.length ? Array.from(new Set(items)) : undefined;
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import { distinctUntilChanged, from, map, Observable, switchMap, timer } from 'rxjs';
|
import { distinctUntilChanged, from, map, Observable, switchMap, timer } from 'rxjs';
|
||||||
import { getIntegerConfig } from '../common/config-number';
|
|
||||||
import { CurrentResponseDto } from './dto/current-response.dto';
|
import { CurrentResponseDto } from './dto/current-response.dto';
|
||||||
import { GetCurrentDto } from './dto/get-current.dto';
|
import { GetCurrentDto } from './dto/get-current.dto';
|
||||||
import { CurrentService } from './current.service';
|
import { CurrentService } from './current.service';
|
||||||
@@ -15,21 +13,13 @@ type CurrentSnapshot = {
|
|||||||
response: CurrentResponseDto;
|
response: CurrentResponseDto;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_CURRENT_EVENTS_POLL_MS = 1_000;
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CurrentEventsService {
|
export class CurrentEventsService {
|
||||||
constructor(
|
constructor(private readonly current: CurrentService) {}
|
||||||
private readonly current: CurrentService,
|
|
||||||
private readonly config: ConfigService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/** Отправляет SSE-события current только при изменении снимка данных по edge/tags. */
|
/** Отправляет SSE-события current только при изменении снимка данных по edge/tags. */
|
||||||
stream(query: GetCurrentDto): Observable<CurrentSseMessage> {
|
stream(query: GetCurrentDto): Observable<CurrentSseMessage> {
|
||||||
const pollMs = Math.max(
|
const pollMs = Number(process.env.CURRENT_EVENTS_POLL_MS);
|
||||||
getIntegerConfig(this.config, 'CURRENT_EVENTS_POLL_MS', DEFAULT_CURRENT_EVENTS_POLL_MS),
|
|
||||||
250,
|
|
||||||
);
|
|
||||||
|
|
||||||
return timer(0, pollMs).pipe(
|
return timer(0, pollMs).pipe(
|
||||||
switchMap(() => from(this.createSnapshot(query))),
|
switchMap(() => from(this.createSnapshot(query))),
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { normalizeRequiredText } from '../common/normalize-text';
|
|
||||||
import { CurrentResponseDto } from './dto/current-response.dto';
|
import { CurrentResponseDto } from './dto/current-response.dto';
|
||||||
import { GetCurrentDto } from './dto/get-current.dto';
|
import { GetCurrentDto } from './dto/get-current.dto';
|
||||||
import { createCurrentResponse } from './current.mapper';
|
import { createCurrentResponse } from './current.mapper';
|
||||||
@@ -11,7 +10,7 @@ export class CurrentService {
|
|||||||
|
|
||||||
/** Нормализует фильтры и возвращает текущие значения по одному edge. */
|
/** Нормализует фильтры и возвращает текущие значения по одному edge. */
|
||||||
async findByEdge(query: GetCurrentDto): Promise<CurrentResponseDto> {
|
async findByEdge(query: GetCurrentDto): Promise<CurrentResponseDto> {
|
||||||
const edge = normalizeRequiredText(query.edge, 'edge');
|
const edge = query.edge;
|
||||||
const tags = query.tags?.length ? query.tags : null;
|
const tags = query.tags?.length ? query.tags : null;
|
||||||
const rows = await this.repository.findByEdge(edge, tags);
|
const rows = await this.repository.findByEdge(edge, tags);
|
||||||
return createCurrentResponse(edge, rows);
|
return createCurrentResponse(edge, rows);
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { Transform } from 'class-transformer';
|
|
||||||
import { IsArray, IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
import { IsArray, IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||||
import { parseCommaSeparatedList } from '../../common/query-list';
|
|
||||||
|
|
||||||
export class GetCurrentDto {
|
export class GetCurrentDto {
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@@ -8,7 +6,6 @@ export class GetCurrentDto {
|
|||||||
edge!: string;
|
edge!: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Transform(({ value }) => parseCommaSeparatedList(value))
|
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@IsString({ each: true })
|
@IsString({ each: true })
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import { Pool, QueryResult, QueryResultRow } from 'pg';
|
import { Pool, QueryResult, QueryResultRow } from 'pg';
|
||||||
import { getIntegerConfig } from '../common/config-number';
|
|
||||||
|
|
||||||
type DatabaseHealth = {
|
type DatabaseHealth = {
|
||||||
now: Date;
|
now: Date;
|
||||||
@@ -13,18 +11,10 @@ type DatabaseHealth = {
|
|||||||
export class DbService implements OnModuleInit, OnModuleDestroy {
|
export class DbService implements OnModuleInit, OnModuleDestroy {
|
||||||
private pool!: Pool;
|
private pool!: Pool;
|
||||||
|
|
||||||
constructor(private readonly config: ConfigService) {}
|
|
||||||
|
|
||||||
/** Создает пул PostgreSQL и проверяет БД до начала обработки запросов. */
|
/** Создает пул PostgreSQL и проверяет БД до начала обработки запросов. */
|
||||||
async onModuleInit(): Promise<void> {
|
async onModuleInit(): Promise<void> {
|
||||||
const connectionString = this.config.get<string>('DATABASE_URL');
|
|
||||||
if (!connectionString) {
|
|
||||||
throw new Error('DATABASE_URL is required.');
|
|
||||||
}
|
|
||||||
|
|
||||||
this.pool = new Pool({
|
this.pool = new Pool({
|
||||||
connectionString,
|
connectionString: process.env.DATABASE_URL,
|
||||||
max: getIntegerConfig(this.config, 'PG_POOL_MAX', 20),
|
|
||||||
idleTimeoutMillis: 30_000,
|
idleTimeoutMillis: 30_000,
|
||||||
connectionTimeoutMillis: 5_000,
|
connectionTimeoutMillis: 5_000,
|
||||||
statement_timeout: 60_000,
|
statement_timeout: 60_000,
|
||||||
|
|||||||
@@ -2,11 +2,6 @@ export type EdgeItemDto = {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
parentId: string | null;
|
parentId: string | null;
|
||||||
tagIds: string[];
|
|
||||||
tagCount: number;
|
|
||||||
currentTagCount: number;
|
|
||||||
liveTagCount: number;
|
|
||||||
lastDataAt: Date | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type EdgeResponseDto = {
|
export type EdgeResponseDto = {
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
import { Transform } from 'class-transformer';
|
|
||||||
import { IsOptional, IsString } from 'class-validator';
|
import { IsOptional, IsString } from 'class-validator';
|
||||||
import { normalizeOptionalText } from '../../common/normalize-text';
|
|
||||||
|
|
||||||
export class GetEdgesDto {
|
export class GetEdgesDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Transform(({ value }) => normalizeOptionalText(value))
|
|
||||||
@IsString()
|
@IsString()
|
||||||
parentId?: string;
|
parentId?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Transform(({ value }) => normalizeOptionalText(value))
|
|
||||||
@IsString()
|
@IsString()
|
||||||
search?: string;
|
search?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,11 +8,6 @@ export function createEdgeResponse(rows: EdgeRow[]): EdgeResponseDto {
|
|||||||
id: row.id,
|
id: row.id,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
parentId: row.parent_id,
|
parentId: row.parent_id,
|
||||||
tagIds: row.tag_ids ?? [],
|
|
||||||
tagCount: row.tag_count,
|
|
||||||
currentTagCount: row.current_tag_count,
|
|
||||||
liveTagCount: row.live_tag_count,
|
|
||||||
lastDataAt: row.last_data_at,
|
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,32 +6,21 @@ import { EdgeRow } from './edge.types';
|
|||||||
export class EdgeRepository {
|
export class EdgeRepository {
|
||||||
constructor(private readonly db: DbService) {}
|
constructor(private readonly db: DbService) {}
|
||||||
|
|
||||||
/** Читает каталог edge с легкими счетчиками свежести из текущих значений. */
|
/** Reads the edge catalog. */
|
||||||
async findAll(parentId: string | null, search: string | null): Promise<EdgeRow[]> {
|
async findAll(parentId: string | null, search: string | null): Promise<EdgeRow[]> {
|
||||||
const result = await this.db.query<EdgeRow>(
|
const result = await this.db.query<EdgeRow>(
|
||||||
`
|
`
|
||||||
SELECT
|
SELECT
|
||||||
e.id,
|
e.id,
|
||||||
e.name,
|
e.name,
|
||||||
e.parent_id,
|
e.parent_id
|
||||||
e.tag_ids,
|
|
||||||
cardinality(e.tag_ids)::integer AS tag_count,
|
|
||||||
count(DISTINCT c.tag)::integer AS current_tag_count,
|
|
||||||
count(DISTINCT c.tag) FILTER (
|
|
||||||
WHERE c."updatedAt" >= now() - INTERVAL '30 seconds'
|
|
||||||
)::integer AS live_tag_count,
|
|
||||||
max(c."updatedAt") AS last_data_at
|
|
||||||
FROM edge AS e
|
FROM edge AS e
|
||||||
LEFT JOIN current AS c
|
|
||||||
ON c.edge = e.id
|
|
||||||
AND (cardinality(e.tag_ids) = 0 OR c.tag = ANY(e.tag_ids))
|
|
||||||
WHERE ($1::varchar IS NULL OR e.parent_id = $1::varchar)
|
WHERE ($1::varchar IS NULL OR e.parent_id = $1::varchar)
|
||||||
AND (
|
AND (
|
||||||
$2::text IS NULL
|
$2::text IS NULL
|
||||||
OR e.id ILIKE $2::text
|
OR e.id ILIKE $2::text
|
||||||
OR e.name ILIKE $2::text
|
OR e.name ILIKE $2::text
|
||||||
)
|
)
|
||||||
GROUP BY e.id, e.name, e.parent_id, e.tag_ids
|
|
||||||
ORDER BY e.name ASC, e.id ASC
|
ORDER BY e.name ASC, e.id ASC
|
||||||
`,
|
`,
|
||||||
[parentId, search],
|
[parentId, search],
|
||||||
|
|||||||
@@ -2,9 +2,4 @@ export type EdgeRow = {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
parent_id: string | null;
|
parent_id: string | null;
|
||||||
tag_ids: string[];
|
|
||||||
tag_count: number;
|
|
||||||
current_tag_count: number;
|
|
||||||
live_tag_count: number;
|
|
||||||
last_data_at: Date | null;
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,41 +1,23 @@
|
|||||||
import { Transform, Type } from 'class-transformer';
|
import { IsDateString, IsNotEmpty, IsString } from 'class-validator';
|
||||||
import {
|
|
||||||
ArrayMaxSize,
|
|
||||||
IsArray,
|
|
||||||
IsDateString,
|
|
||||||
IsNotEmpty,
|
|
||||||
IsNumber,
|
|
||||||
IsOptional,
|
|
||||||
IsString,
|
|
||||||
Max,
|
|
||||||
Min,
|
|
||||||
} from 'class-validator';
|
|
||||||
import { parseCommaSeparatedList } from '../../common/query-list';
|
|
||||||
|
|
||||||
export class GetHistoryDto {
|
export class GetHistoryDto {
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@IsString()
|
@IsString()
|
||||||
edge!: string;
|
edge!: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsNotEmpty()
|
||||||
@Transform(({ value }) => parseCommaSeparatedList(value))
|
@IsString()
|
||||||
@IsArray()
|
tag!: string;
|
||||||
@ArrayMaxSize(500)
|
|
||||||
@IsString({ each: true })
|
|
||||||
tags?: string[];
|
|
||||||
|
|
||||||
@IsOptional()
|
@IsNotEmpty()
|
||||||
@IsDateString()
|
@IsDateString()
|
||||||
from?: string;
|
from!: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsNotEmpty()
|
||||||
@IsDateString()
|
@IsDateString()
|
||||||
to?: string;
|
to!: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsNotEmpty()
|
||||||
@Type(() => Number)
|
@IsString()
|
||||||
@IsNumber()
|
granulate!: string;
|
||||||
@Min(100)
|
|
||||||
@Max(5000)
|
|
||||||
targetPoints?: number;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,11 @@
|
|||||||
export type HistoryPointDto = {
|
export type HistoryPointDto = {
|
||||||
t: number;
|
time: Date;
|
||||||
v: number;
|
min_value: number;
|
||||||
min: number;
|
avg_value: number;
|
||||||
avg: number;
|
max_value: number;
|
||||||
max: number;
|
point_count: number;
|
||||||
count: number;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type HistorySeriesDto = {
|
|
||||||
edge: string;
|
|
||||||
tag: string;
|
|
||||||
points: HistoryPointDto[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type HistoryResponseSource = 'empty' | 'latest' | 'raw' | '1m' | '5m' | '1h' | '1d';
|
|
||||||
|
|
||||||
export type HistoryResponseDto = {
|
export type HistoryResponseDto = {
|
||||||
edge: string;
|
rows: HistoryPointDto[];
|
||||||
source: HistoryResponseSource;
|
|
||||||
targetPoints: number;
|
|
||||||
series: HistorySeriesDto[];
|
|
||||||
from?: Date;
|
|
||||||
to?: Date;
|
|
||||||
resolutionSeconds?: number | null;
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
import { HistorySource } from './history.types';
|
|
||||||
|
|
||||||
export const RAW_HISTORY_SOURCE: HistorySource = {
|
|
||||||
name: 'raw',
|
|
||||||
table: 'history',
|
|
||||||
timeColumn: 'timestamp',
|
|
||||||
intervalSeconds: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const AGGREGATE_HISTORY_SOURCES: HistorySource[] = [
|
|
||||||
{ name: '1m', table: 'history_1m', timeColumn: 'bucket', intervalSeconds: 60 },
|
|
||||||
{ name: '5m', table: 'history_5m', timeColumn: 'bucket', intervalSeconds: 300 },
|
|
||||||
{ name: '1h', table: 'history_1h', timeColumn: 'bucket', intervalSeconds: 3600 },
|
|
||||||
{ name: '1d', table: 'history_1d', timeColumn: 'bucket', intervalSeconds: 86400 },
|
|
||||||
];
|
|
||||||
|
|
||||||
/** Подбирает самый детальный слой, который не превышает целевой бюджет точек. */
|
|
||||||
export function chooseHistorySource(from: Date, to: Date, targetPoints: number): HistorySource {
|
|
||||||
const durationSeconds = Math.max((to.getTime() - from.getTime()) / 1000, 1);
|
|
||||||
const desiredSeconds = durationSeconds / Math.max(targetPoints, 1);
|
|
||||||
|
|
||||||
if (desiredSeconds < 60) {
|
|
||||||
return RAW_HISTORY_SOURCE;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
AGGREGATE_HISTORY_SOURCES.find((source) => source.intervalSeconds >= desiredSeconds) ??
|
|
||||||
AGGREGATE_HISTORY_SOURCES[AGGREGATE_HISTORY_SOURCES.length - 1]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
import {
|
|
||||||
HistoryResponseDto,
|
|
||||||
HistoryResponseSource,
|
|
||||||
HistorySeriesDto,
|
|
||||||
} from './dto/history-response.dto';
|
|
||||||
import { HistoryRow } from './history.types';
|
|
||||||
|
|
||||||
type HistoryResponseOptions = {
|
|
||||||
edge: string;
|
|
||||||
source: HistoryResponseSource;
|
|
||||||
targetPoints: number;
|
|
||||||
rows?: HistoryRow[];
|
|
||||||
from?: Date;
|
|
||||||
to?: Date;
|
|
||||||
resolutionSeconds?: number | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Превращает плоские строки истории в стабильный API-формат для графиков. */
|
|
||||||
export function createHistoryResponse(options: HistoryResponseOptions): HistoryResponseDto {
|
|
||||||
return {
|
|
||||||
edge: options.edge,
|
|
||||||
source: options.source,
|
|
||||||
targetPoints: options.targetPoints,
|
|
||||||
series: mapHistoryRowsToSeries(options.rows ?? []),
|
|
||||||
from: options.from,
|
|
||||||
to: options.to,
|
|
||||||
resolutionSeconds: options.resolutionSeconds,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Группирует строки БД по edge/tag и переводит Date в миллисекунды Unix. */
|
|
||||||
function mapHistoryRowsToSeries(rows: HistoryRow[]): HistorySeriesDto[] {
|
|
||||||
const seriesMap = new Map<string, HistorySeriesDto>();
|
|
||||||
|
|
||||||
for (const row of rows) {
|
|
||||||
const key = `${row.edge}\u0000${row.tag}`;
|
|
||||||
const series = seriesMap.get(key) ?? {
|
|
||||||
edge: row.edge,
|
|
||||||
tag: row.tag,
|
|
||||||
points: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
series.points.push({
|
|
||||||
t: row.time.getTime(),
|
|
||||||
v: row.value,
|
|
||||||
min: row.min_value,
|
|
||||||
avg: row.avg_value,
|
|
||||||
max: row.max_value,
|
|
||||||
count: row.point_count,
|
|
||||||
});
|
|
||||||
seriesMap.set(key, series);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.from(seriesMap.values());
|
|
||||||
}
|
|
||||||
@@ -1,160 +1,38 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { DbService } from '../db/db.service';
|
import { DbService } from '../db/db.service';
|
||||||
import { HistoryRow, HistorySource } from './history.types';
|
import { HistoryRow } from './history.types';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class HistoryRepository {
|
export class HistoryRepository {
|
||||||
constructor(private readonly db: DbService) {}
|
constructor(private readonly db: DbService) {}
|
||||||
|
|
||||||
/** Загружает последние N сырых точек истории для каждого запрошенного тега. */
|
/** Агрегирует сырую историю по одному тегу через TimescaleDB time_bucket. */
|
||||||
async findLatest(edge: string, tags: string[], targetPoints: number): Promise<HistoryRow[]> {
|
async findBucketedRange(
|
||||||
const result = await this.db.query<HistoryRow>(
|
|
||||||
`
|
|
||||||
SELECT
|
|
||||||
latest.edge,
|
|
||||||
latest.tag,
|
|
||||||
latest.timestamp AS time,
|
|
||||||
latest.value,
|
|
||||||
latest.value AS min_value,
|
|
||||||
latest.value AS avg_value,
|
|
||||||
latest.value AS max_value,
|
|
||||||
1::integer AS point_count
|
|
||||||
FROM unnest($2::varchar[]) AS requested(tag)
|
|
||||||
CROSS JOIN LATERAL (
|
|
||||||
SELECT edge, tag, timestamp, value
|
|
||||||
FROM history
|
|
||||||
WHERE edge = $1
|
|
||||||
AND tag = requested.tag
|
|
||||||
ORDER BY timestamp DESC
|
|
||||||
LIMIT $3
|
|
||||||
) AS latest
|
|
||||||
ORDER BY latest.tag ASC, latest.timestamp ASC
|
|
||||||
`,
|
|
||||||
[edge, tags, targetPoints],
|
|
||||||
);
|
|
||||||
|
|
||||||
return result.rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Загружает сырую историю за диапазон и агрегирует ее в бакеты под бюджет точек. */
|
|
||||||
async findRawRange(
|
|
||||||
edge: string,
|
edge: string,
|
||||||
tags: string[],
|
tag: string,
|
||||||
from: Date,
|
from: Date,
|
||||||
to: Date,
|
to: Date,
|
||||||
targetPoints: number,
|
granulate: string,
|
||||||
): Promise<HistoryRow[]> {
|
): Promise<HistoryRow[]> {
|
||||||
const bucketMillis = this.getBucketMillis(from, to, targetPoints);
|
|
||||||
const fromMillis = from.getTime();
|
|
||||||
const result = await this.db.query<HistoryRow>(
|
const result = await this.db.query<HistoryRow>(
|
||||||
`
|
`
|
||||||
WITH filtered AS (
|
|
||||||
SELECT
|
SELECT
|
||||||
edge,
|
time_bucket($5::interval, timestamp) AS time,
|
||||||
tag,
|
|
||||||
floor((extract(epoch FROM timestamp) * 1000 - $5) / $6)::bigint AS bucket_id,
|
|
||||||
value
|
|
||||||
FROM history
|
|
||||||
WHERE edge = $1
|
|
||||||
AND tag = ANY($2::varchar[])
|
|
||||||
AND timestamp >= $3
|
|
||||||
AND timestamp <= $4
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
edge,
|
|
||||||
tag,
|
|
||||||
to_timestamp(($5 + bucket_id * $6) / 1000.0) AS time,
|
|
||||||
avg(value)::double precision AS value,
|
|
||||||
min(value)::double precision AS min_value,
|
min(value)::double precision AS min_value,
|
||||||
avg(value)::double precision AS avg_value,
|
avg(value)::double precision AS avg_value,
|
||||||
max(value)::double precision AS max_value,
|
max(value)::double precision AS max_value,
|
||||||
count(*)::integer AS point_count
|
count(*)::integer AS point_count
|
||||||
FROM filtered
|
|
||||||
GROUP BY edge, tag, bucket_id
|
|
||||||
ORDER BY tag ASC, time ASC
|
|
||||||
`,
|
|
||||||
[edge, tags, from, to, fromMillis, bucketMillis],
|
|
||||||
);
|
|
||||||
|
|
||||||
return result.rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Читает заранее подготовленный aggregate-view с min/avg/max/count по бакетам. */
|
|
||||||
async findAggregateRange(
|
|
||||||
edge: string,
|
|
||||||
tags: string[],
|
|
||||||
from: Date,
|
|
||||||
to: Date,
|
|
||||||
source: HistorySource,
|
|
||||||
): Promise<HistoryRow[]> {
|
|
||||||
const result = await this.db.query<HistoryRow>(
|
|
||||||
`
|
|
||||||
SELECT
|
|
||||||
edge,
|
|
||||||
tag,
|
|
||||||
${source.timeColumn} AS time,
|
|
||||||
avg_value AS value,
|
|
||||||
min_value,
|
|
||||||
avg_value,
|
|
||||||
max_value,
|
|
||||||
point_count
|
|
||||||
FROM ${source.table}
|
|
||||||
WHERE edge = $1
|
|
||||||
AND tag = ANY($2::varchar[])
|
|
||||||
AND ${source.timeColumn} >= $3
|
|
||||||
AND ${source.timeColumn} <= $4
|
|
||||||
ORDER BY tag ASC, ${source.timeColumn} ASC
|
|
||||||
`,
|
|
||||||
[edge, tags, from, to],
|
|
||||||
);
|
|
||||||
|
|
||||||
return result.rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Рассчитывает ширину временного бакета так, чтобы на тег было не больше targetPoints точек. */
|
|
||||||
private getBucketMillis(from: Date, to: Date, targetPoints: number): number {
|
|
||||||
const durationMillis = Math.max(to.getTime() - from.getTime(), 1);
|
|
||||||
return Math.max(Math.ceil(durationMillis / Math.max(targetPoints, 1)), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Берет теги из запроса или ищет связи edge, а затем резервно смотрит саму историю. */
|
|
||||||
async resolveTags(edge: string, requestedTags?: string[]): Promise<string[]> {
|
|
||||||
if (requestedTags?.length) {
|
|
||||||
return requestedTags;
|
|
||||||
}
|
|
||||||
|
|
||||||
const linked = await this.db.query<{ tag: string }>(
|
|
||||||
`
|
|
||||||
SELECT DISTINCT tag
|
|
||||||
FROM (
|
|
||||||
SELECT unnest(e.tag_ids)::text AS tag
|
|
||||||
FROM edge AS e
|
|
||||||
WHERE e.id = $1
|
|
||||||
UNION
|
|
||||||
SELECT t.id AS tag
|
|
||||||
FROM tag AS t
|
|
||||||
WHERE $1 = ANY(t.edge_ids)
|
|
||||||
) AS linked_tags
|
|
||||||
WHERE tag IS NOT NULL
|
|
||||||
ORDER BY tag ASC
|
|
||||||
`,
|
|
||||||
[edge],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (linked.rows.length) {
|
|
||||||
return linked.rows.map((row) => row.tag);
|
|
||||||
}
|
|
||||||
|
|
||||||
const distinct = await this.db.query<{ tag: string }>(
|
|
||||||
`
|
|
||||||
SELECT DISTINCT tag
|
|
||||||
FROM history
|
FROM history
|
||||||
WHERE edge = $1
|
WHERE edge = $1
|
||||||
ORDER BY tag ASC
|
AND tag = $2
|
||||||
|
AND timestamp >= $3
|
||||||
|
AND timestamp < $4
|
||||||
|
GROUP BY time
|
||||||
|
ORDER BY time ASC
|
||||||
`,
|
`,
|
||||||
[edge],
|
[edge, tag, from, to, granulate],
|
||||||
);
|
);
|
||||||
|
|
||||||
return distinct.rows.map((row) => row.tag);
|
return result.rows;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,66 +1,16 @@
|
|||||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import { getIntegerConfig } from '../common/config-number';
|
|
||||||
import { normalizeRequiredText } from '../common/normalize-text';
|
|
||||||
import { GetHistoryDto } from './dto/get-history.dto';
|
import { GetHistoryDto } from './dto/get-history.dto';
|
||||||
import { HistoryResponseDto } from './dto/history-response.dto';
|
import { HistoryResponseDto } from './dto/history-response.dto';
|
||||||
import { chooseHistorySource } from './history-source';
|
|
||||||
import { createHistoryResponse } from './history.mapper';
|
|
||||||
import { HistoryRepository } from './history.repository';
|
import { HistoryRepository } from './history.repository';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class HistoryService {
|
export class HistoryService {
|
||||||
constructor(
|
constructor(private readonly repository: HistoryRepository) {}
|
||||||
private readonly repository: HistoryRepository,
|
|
||||||
private readonly config: ConfigService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/** Определяет теги, выбирает слой истории и собирает API-ответ для графика. */
|
/** Получает агрегированную историю одного тега за диапазон с заданным шагом bucket. */
|
||||||
async findSeries(query: GetHistoryDto): Promise<HistoryResponseDto> {
|
async findSeries(query: GetHistoryDto): Promise<HistoryResponseDto> {
|
||||||
const edge = normalizeRequiredText(query.edge, 'edge');
|
const from = new Date(query.from);
|
||||||
const targetPoints = this.getTargetPoints(query.targetPoints);
|
const to = new Date(query.to);
|
||||||
const tags = await this.repository.resolveTags(edge, query.tags);
|
|
||||||
|
|
||||||
if (!tags.length) {
|
|
||||||
return createHistoryResponse({
|
|
||||||
edge,
|
|
||||||
source: 'empty',
|
|
||||||
targetPoints,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!query.from && !query.to) {
|
|
||||||
const rows = await this.repository.findLatest(edge, tags, targetPoints);
|
|
||||||
return createHistoryResponse({
|
|
||||||
edge,
|
|
||||||
source: 'latest',
|
|
||||||
targetPoints,
|
|
||||||
rows,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const { from, to } = this.parseRange(query);
|
|
||||||
const source = chooseHistorySource(from, to, targetPoints);
|
|
||||||
const rows =
|
|
||||||
source.name === 'raw'
|
|
||||||
? await this.repository.findRawRange(edge, tags, from, to, targetPoints)
|
|
||||||
: await this.repository.findAggregateRange(edge, tags, from, to, source);
|
|
||||||
|
|
||||||
return createHistoryResponse({
|
|
||||||
edge,
|
|
||||||
from,
|
|
||||||
to,
|
|
||||||
source: source.name,
|
|
||||||
resolutionSeconds: source.intervalSeconds || null,
|
|
||||||
targetPoints,
|
|
||||||
rows,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Разбирает опциональный диапазон времени и отклоняет неверные или обратные границы. */
|
|
||||||
private parseRange(query: GetHistoryDto): { from: Date; to: Date } {
|
|
||||||
const from = query.from ? new Date(query.from) : new Date(0);
|
|
||||||
const to = query.to ? new Date(query.to) : new Date();
|
|
||||||
|
|
||||||
if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime())) {
|
if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime())) {
|
||||||
throw new BadRequestException('Invalid from/to date.');
|
throw new BadRequestException('Invalid from/to date.');
|
||||||
@@ -70,14 +20,7 @@ export class HistoryService {
|
|||||||
throw new BadRequestException('from cannot be greater than to.');
|
throw new BadRequestException('from cannot be greater than to.');
|
||||||
}
|
}
|
||||||
|
|
||||||
return { from, to };
|
const rows = await this.repository.findBucketedRange(query.edge, query.tag, from, to, query.granulate);
|
||||||
}
|
return { rows };
|
||||||
|
|
||||||
/** Применяет лимиты точек по умолчанию и максимуму, чтобы ответ истории был удобным для графиков. */
|
|
||||||
private getTargetPoints(requested?: number): number {
|
|
||||||
const defaultValue = getIntegerConfig(this.config, 'HISTORY_TARGET_POINTS', 2000);
|
|
||||||
const maxValue = getIntegerConfig(this.config, 'HISTORY_MAX_TARGET_POINTS', 5000);
|
|
||||||
const value = requested ?? defaultValue;
|
|
||||||
return Math.min(Math.max(Math.floor(value), 100), maxValue);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,5 @@
|
|||||||
export type HistorySourceName = 'raw' | '1m' | '5m' | '1h' | '1d';
|
|
||||||
|
|
||||||
export type HistorySource = {
|
|
||||||
name: HistorySourceName;
|
|
||||||
table: string;
|
|
||||||
timeColumn: string;
|
|
||||||
intervalSeconds: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type HistoryRow = {
|
export type HistoryRow = {
|
||||||
edge: string;
|
|
||||||
tag: string;
|
|
||||||
time: Date;
|
time: Date;
|
||||||
value: number;
|
|
||||||
min_value: number;
|
min_value: number;
|
||||||
avg_value: number;
|
avg_value: number;
|
||||||
max_value: number;
|
max_value: number;
|
||||||
|
|||||||
@@ -1,15 +1,8 @@
|
|||||||
import { Type } from 'class-transformer';
|
import { IsDateString, IsObject } from 'class-validator';
|
||||||
import { IsDateString, IsNumber, IsObject, IsOptional } from 'class-validator';
|
|
||||||
|
|
||||||
export class IngestEdgeValuesDto {
|
export class IngestEdgeValuesDto {
|
||||||
@IsOptional()
|
|
||||||
@IsDateString()
|
@IsDateString()
|
||||||
time?: string;
|
timestamp!: string;
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@Type(() => Number)
|
|
||||||
@IsNumber()
|
|
||||||
timestamp?: number;
|
|
||||||
|
|
||||||
@IsObject()
|
@IsObject()
|
||||||
values!: Record<string, number>;
|
values!: Record<string, number>;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import { IsDateString, IsNotEmpty, IsNumber, IsOptional, IsString } from 'class-validator';
|
import { IsDateString, IsNotEmpty, IsNumber, IsString } from 'class-validator';
|
||||||
|
|
||||||
export class IngestPointDto {
|
export class IngestPointDto {
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@@ -10,14 +10,8 @@ export class IngestPointDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
tag!: string;
|
tag!: string;
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsDateString()
|
@IsDateString()
|
||||||
time?: string;
|
timestamp!: string;
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@Type(() => Number)
|
|
||||||
@IsNumber()
|
|
||||||
timestamp?: number;
|
|
||||||
|
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Body, Controller, Param, ParseArrayPipe, Post, UseGuards } from '@nestjs/common';
|
import { Body, Controller, Param, Post, UseGuards } from '@nestjs/common';
|
||||||
import { IngestApiKeyGuard } from '../common/ingest-api-key.guard';
|
import { IngestApiKeyGuard } from '../common/ingest-api-key.guard';
|
||||||
import { IngestEdgeValuesDto } from './dto/ingest-edge-values.dto';
|
import { IngestEdgeValuesDto } from './dto/ingest-edge-values.dto';
|
||||||
import { IngestPointDto } from './dto/ingest-point.dto';
|
import { IngestPointDto } from './dto/ingest-point.dto';
|
||||||
@@ -9,23 +9,19 @@ import { IngestService } from './ingest.service';
|
|||||||
export class IngestController {
|
export class IngestController {
|
||||||
constructor(private readonly ingest: IngestService) {}
|
constructor(private readonly ingest: IngestService) {}
|
||||||
|
|
||||||
/** Принимает массив сырых точек и сохраняет их как записи истории. */
|
/** Принимает одну сырую точку и сохраняет ее как запись истории/current. */
|
||||||
@Post()
|
@Post()
|
||||||
ingestPoints(
|
ingestPoint(@Body() point: IngestPointDto) {
|
||||||
@Body(new ParseArrayPipe({ items: IngestPointDto }))
|
return this.ingest.ingestPoint(point);
|
||||||
points: IngestPointDto[],
|
|
||||||
) {
|
|
||||||
return this.ingest.ingestPoints(points);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Принимает компактное тело запроса по edge и разворачивает каждое значение в точку истории. */
|
/** Принимает компактное тело запроса по edge и сохраняет каждое значение построчно. */
|
||||||
@Post(':edge')
|
@Post(':edge')
|
||||||
ingestEdgeValues(@Param('edge') edge: string, @Body() body: IngestEdgeValuesDto) {
|
ingestEdgeValues(@Param('edge') edge: string, @Body() body: IngestEdgeValuesDto) {
|
||||||
const points = Object.entries(body.values).map(([tag, value]) => ({
|
const points = Object.entries(body.values).map(([tag, value]) => ({
|
||||||
edge,
|
edge,
|
||||||
tag,
|
tag,
|
||||||
value: Number(value),
|
value: Number(value),
|
||||||
time: body.time,
|
|
||||||
timestamp: body.timestamp,
|
timestamp: body.timestamp,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -1,138 +1,49 @@
|
|||||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import { getIntegerConfig } from '../common/config-number';
|
|
||||||
import { normalizeRequiredText } from '../common/normalize-text';
|
|
||||||
import { DbService } from '../db/db.service';
|
import { DbService } from '../db/db.service';
|
||||||
import { IngestPointDto } from './dto/ingest-point.dto';
|
import { IngestPointDto } from './dto/ingest-point.dto';
|
||||||
|
|
||||||
const DEFAULT_MAX_BATCH_SIZE = 10_000;
|
|
||||||
|
|
||||||
type NormalizedPoint = {
|
|
||||||
time: Date;
|
|
||||||
edge: string;
|
|
||||||
tag: string;
|
|
||||||
value: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type IngestResult = {
|
type IngestResult = {
|
||||||
processed: number;
|
processed: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class IngestService {
|
export class IngestService {
|
||||||
constructor(
|
constructor(private readonly db: DbService) {}
|
||||||
private readonly db: DbService,
|
|
||||||
private readonly config: ConfigService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/** Проверяет батч, пишет сырую историю и обновляет последний снимок current для UI/SSE. */
|
|
||||||
async ingestPoints(points: IngestPointDto[]): Promise<IngestResult> {
|
|
||||||
if (!points.length) {
|
|
||||||
return { processed: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
this.assertBatchSize(points.length);
|
|
||||||
|
|
||||||
const normalized = points.map((point) => this.normalizePoint(point));
|
|
||||||
|
|
||||||
|
/** Пишет одну входящую точку в history и обновляет текущий снимок current для UI/SSE. */
|
||||||
|
async ingestPoint(point: IngestPointDto): Promise<IngestResult> {
|
||||||
await this.db.query(
|
await this.db.query(
|
||||||
`
|
`
|
||||||
INSERT INTO history (timestamp, edge, tag, value)
|
INSERT INTO history (timestamp, edge, tag, value)
|
||||||
SELECT time, edge, tag, value
|
VALUES ($1, $2, $3, $4)
|
||||||
FROM unnest(
|
|
||||||
$1::timestamp[],
|
|
||||||
$2::varchar[],
|
|
||||||
$3::varchar[],
|
|
||||||
$4::double precision[]
|
|
||||||
) AS point(time, edge, tag, value)
|
|
||||||
`,
|
`,
|
||||||
[
|
[point.timestamp, point.edge, point.tag, point.value],
|
||||||
normalized.map((point) => point.time),
|
|
||||||
normalized.map((point) => point.edge),
|
|
||||||
normalized.map((point) => point.tag),
|
|
||||||
normalized.map((point) => point.value),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
await this.db.query(
|
await this.db.query(
|
||||||
`
|
`
|
||||||
INSERT INTO current (edge, tag, value, "createdAt", "updatedAt")
|
INSERT INTO current (edge, tag, value, "updatedAt")
|
||||||
SELECT DISTINCT ON (edge, tag)
|
VALUES ($1, $2, $3, NOW())
|
||||||
edge,
|
|
||||||
tag,
|
|
||||||
value,
|
|
||||||
time AS "createdAt",
|
|
||||||
time AS "updatedAt"
|
|
||||||
FROM unnest(
|
|
||||||
$1::timestamp[],
|
|
||||||
$2::varchar[],
|
|
||||||
$3::varchar[],
|
|
||||||
$4::double precision[]
|
|
||||||
) AS point(time, edge, tag, value)
|
|
||||||
ORDER BY edge ASC, tag ASC, time DESC
|
|
||||||
ON CONFLICT (edge, tag)
|
ON CONFLICT (edge, tag)
|
||||||
DO UPDATE SET
|
DO UPDATE SET
|
||||||
value = EXCLUDED.value,
|
value = $3,
|
||||||
"updatedAt" = EXCLUDED."updatedAt"
|
"updatedAt" = NOW()
|
||||||
WHERE current."updatedAt" <= EXCLUDED."updatedAt"
|
|
||||||
`,
|
`,
|
||||||
[
|
[point.edge, point.tag, point.value],
|
||||||
normalized.map((point) => point.time),
|
|
||||||
normalized.map((point) => point.edge),
|
|
||||||
normalized.map((point) => point.tag),
|
|
||||||
normalized.map((point) => point.value),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return { processed: normalized.length };
|
return { processed: 1 };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Защищает API от слишком больших ingest-запросов, занимающих соединение с БД надолго. */
|
/** Сохраняет несколько точек без batch SQL: каждая точка проходит тот же линейный путь. */
|
||||||
private assertBatchSize(size: number): void {
|
async ingestPoints(points: IngestPointDto[]): Promise<IngestResult> {
|
||||||
const maxBatchSize = Math.max(
|
let processed = 0;
|
||||||
getIntegerConfig(this.config, 'INGEST_MAX_BATCH_SIZE', DEFAULT_MAX_BATCH_SIZE),
|
|
||||||
1,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (size > maxBatchSize) {
|
for (const point of points) {
|
||||||
throw new BadRequestException(`Batch size must not exceed ${maxBatchSize} points.`);
|
const result = await this.ingestPoint(point);
|
||||||
}
|
processed += result.processed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Приводит входной DTO к нормализованному виду для вставки в history. */
|
return { processed };
|
||||||
private normalizePoint(point: IngestPointDto): NormalizedPoint {
|
|
||||||
const edge = normalizeRequiredText(point.edge, 'edge');
|
|
||||||
const tag = normalizeRequiredText(point.tag, 'tag');
|
|
||||||
const time = this.parsePointTime(point);
|
|
||||||
|
|
||||||
if (!Number.isFinite(point.value)) {
|
|
||||||
throw new BadRequestException('value must be a finite number.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
edge,
|
|
||||||
tag,
|
|
||||||
value: point.value,
|
|
||||||
time,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Принимает ISO-время или миллисекунды Unix и отклоняет точки без валидного времени. */
|
|
||||||
private parsePointTime(point: IngestPointDto): Date {
|
|
||||||
if (point.time) {
|
|
||||||
const date = new Date(point.time);
|
|
||||||
if (!Number.isNaN(date.getTime())) {
|
|
||||||
return date;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (point.timestamp !== undefined) {
|
|
||||||
const date = new Date(point.timestamp);
|
|
||||||
if (!Number.isNaN(date.getTime())) {
|
|
||||||
return date;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new BadRequestException('Each point must contain valid time or timestamp.');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,12 +8,8 @@ async function bootstrap() {
|
|||||||
const app = await NestFactory.create(AppModule);
|
const app = await NestFactory.create(AppModule);
|
||||||
app.use(compression());
|
app.use(compression());
|
||||||
|
|
||||||
const corsAllowedOrigins = process.env.CORS_ALLOWED_ORIGINS
|
|
||||||
? process.env.CORS_ALLOWED_ORIGINS.split(',').map((origin) => origin.trim()).filter(Boolean)
|
|
||||||
: true;
|
|
||||||
|
|
||||||
app.enableCors({
|
app.enableCors({
|
||||||
origin: corsAllowedOrigins,
|
origin: process.env.CORS_ALLOWED_ORIGINS,
|
||||||
credentials: true,
|
credentials: true,
|
||||||
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||||
allowedHeaders: ['Content-Type', 'Authorization', 'x-api-key'],
|
allowedHeaders: ['Content-Type', 'Authorization', 'x-api-key'],
|
||||||
@@ -27,7 +23,7 @@ async function bootstrap() {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
await app.listen(process.env.PORT ?? 3101);
|
await app.listen(process.env.PORT as string);
|
||||||
}
|
}
|
||||||
|
|
||||||
void bootstrap();
|
void bootstrap();
|
||||||
|
|||||||
@@ -1,15 +1,7 @@
|
|||||||
import { Transform } from 'class-transformer';
|
|
||||||
import { IsOptional, IsString } from 'class-validator';
|
import { IsOptional, IsString } from 'class-validator';
|
||||||
import { normalizeOptionalText } from '../../common/normalize-text';
|
|
||||||
|
|
||||||
export class GetTagsDto {
|
export class GetTagsDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Transform(({ value }) => normalizeOptionalText(value))
|
|
||||||
@IsString()
|
|
||||||
edge?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@Transform(({ value }) => normalizeOptionalText(value))
|
|
||||||
@IsString()
|
@IsString()
|
||||||
search?: string;
|
search?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,10 @@ export type TagItemDto = {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
tagGroup: string | null;
|
tagGroup: string | null;
|
||||||
min: number;
|
min: number | null;
|
||||||
max: number;
|
max: number | null;
|
||||||
comment: string;
|
comment: string;
|
||||||
unitOfMeasurement: string;
|
unitOfMeasurement: string;
|
||||||
edgeIds: string[];
|
|
||||||
precision: number | null;
|
precision: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ export function createTagResponse(rows: TagRow[]): TagResponseDto {
|
|||||||
max: row.max,
|
max: row.max,
|
||||||
comment: row.comment,
|
comment: row.comment,
|
||||||
unitOfMeasurement: row.unit_of_measurement,
|
unitOfMeasurement: row.unit_of_measurement,
|
||||||
edgeIds: row.edge_ids ?? [],
|
|
||||||
precision: row.precision,
|
precision: row.precision,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import { TagRow } from './tag.types';
|
|||||||
export class TagRepository {
|
export class TagRepository {
|
||||||
constructor(private readonly db: DbService) {}
|
constructor(private readonly db: DbService) {}
|
||||||
|
|
||||||
/** Читает метаданные тегов с учетом связей из tag.edge_ids и edge.tag_ids. */
|
/** Reads the tag catalog. */
|
||||||
async findAll(edge: string | null, search: string | null): Promise<TagRow[]> {
|
async findAll(search: string | null): Promise<TagRow[]> {
|
||||||
const result = await this.db.query<TagRow>(
|
const result = await this.db.query<TagRow>(
|
||||||
`
|
`
|
||||||
SELECT
|
SELECT
|
||||||
@@ -18,31 +18,20 @@ export class TagRepository {
|
|||||||
t.max,
|
t.max,
|
||||||
t.comment,
|
t.comment,
|
||||||
t.unit_of_measurement,
|
t.unit_of_measurement,
|
||||||
t.edge_ids,
|
|
||||||
t.precision
|
t.precision
|
||||||
FROM tag AS t
|
FROM tag AS t
|
||||||
WHERE (
|
WHERE (
|
||||||
$1::varchar IS NULL
|
$1::text IS NULL
|
||||||
OR $1::varchar = ANY(t.edge_ids)
|
OR t.id ILIKE $1::text
|
||||||
OR EXISTS (
|
OR t.name ILIKE $1::text
|
||||||
SELECT 1
|
OR t.comment ILIKE $1::text
|
||||||
FROM edge AS e
|
|
||||||
WHERE e.id = $1::varchar
|
|
||||||
AND t.id = ANY(e.tag_ids)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
AND (
|
|
||||||
$2::text IS NULL
|
|
||||||
OR t.id ILIKE $2::text
|
|
||||||
OR t.name ILIKE $2::text
|
|
||||||
OR t.comment ILIKE $2::text
|
|
||||||
)
|
)
|
||||||
ORDER BY
|
ORDER BY
|
||||||
NULLIF(t.tag_group, '') ASC NULLS LAST,
|
NULLIF(t.tag_group, '') ASC NULLS LAST,
|
||||||
t.name ASC,
|
t.name ASC,
|
||||||
t.id ASC
|
t.id ASC
|
||||||
`,
|
`,
|
||||||
[edge, search],
|
[search],
|
||||||
);
|
);
|
||||||
|
|
||||||
return result.rows;
|
return result.rows;
|
||||||
|
|||||||
@@ -10,9 +10,8 @@ export class TagService {
|
|||||||
|
|
||||||
/** Нормализует фильтры и возвращает метаданные тегов для UI. */
|
/** Нормализует фильтры и возвращает метаданные тегов для UI. */
|
||||||
async findAll(query: GetTagsDto): Promise<TagResponseDto> {
|
async findAll(query: GetTagsDto): Promise<TagResponseDto> {
|
||||||
const edge = query.edge ?? null;
|
|
||||||
const search = query.search ? `%${query.search}%` : null;
|
const search = query.search ? `%${query.search}%` : null;
|
||||||
const rows = await this.repository.findAll(edge, search);
|
const rows = await this.repository.findAll(search);
|
||||||
return createTagResponse(rows);
|
return createTagResponse(rows);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,9 @@ export type TagRow = {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
tag_group: string | null;
|
tag_group: string | null;
|
||||||
min: number;
|
min: number | null;
|
||||||
max: number;
|
max: number | null;
|
||||||
comment: string;
|
comment: string;
|
||||||
unit_of_measurement: string;
|
unit_of_measurement: string;
|
||||||
edge_ids: string[];
|
|
||||||
precision: number | null;
|
precision: number | null;
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user