first commit
This commit is contained in:
19
.env.example
Normal file
19
.env.example
Normal file
@@ -0,0 +1,19 @@
|
||||
PORT=3101
|
||||
DATABASE_URL=postgres://greact:pG3526l4@194.36.208.86:5433/cloud
|
||||
CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173
|
||||
|
||||
# Optional. When set, POST /ingest requires x-api-key: <value>.
|
||||
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.
|
||||
CURRENT_EVENTS_POLL_MS=1000
|
||||
|
||||
# Graph API defaults.
|
||||
HISTORY_TARGET_POINTS=2000
|
||||
HISTORY_MAX_TARGET_POINTS=5000
|
||||
9
.env.local.example
Normal file
9
.env.local.example
Normal file
@@ -0,0 +1,9 @@
|
||||
PORT=3101
|
||||
DATABASE_URL=postgres://greact:pG3526l4@194.36.208.86:5433/cloud
|
||||
CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173
|
||||
INGEST_API_KEY=
|
||||
PG_POOL_MAX=20
|
||||
INGEST_MAX_BATCH_SIZE=10000
|
||||
CURRENT_EVENTS_POLL_MS=1000
|
||||
HISTORY_TARGET_POINTS=2000
|
||||
HISTORY_MAX_TARGET_POINTS=5000
|
||||
36
.gitignore
vendored
Normal file
36
.gitignore
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
coverage/
|
||||
*.tsbuildinfo
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.local.example
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# Runtime/dev artifacts
|
||||
*.pid
|
||||
*.seed
|
||||
*.local
|
||||
|
||||
# IDE and OS files
|
||||
.idea/
|
||||
.vscode/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Tool caches
|
||||
.eslintcache
|
||||
.npm/
|
||||
.cache/
|
||||
18
Dockerfile
Normal file
18
Dockerfile
Normal file
@@ -0,0 +1,18 @@
|
||||
FROM node:22-alpine AS deps
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM node:22-alpine AS runtime
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
COPY package*.json ./
|
||||
RUN npm ci --omit=dev
|
||||
COPY --from=build /app/dist ./dist
|
||||
CMD ["node", "dist/main.js"]
|
||||
11
LOCAL_NO_DOCKER.md
Normal file
11
LOCAL_NO_DOCKER.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# Local Run Without Docker
|
||||
|
||||
```bash
|
||||
npm install
|
||||
cp .env.example .env
|
||||
npm run start:dev
|
||||
```
|
||||
|
||||
The API listens on `PORT`, default `3101`.
|
||||
|
||||
This service does not run migrations. It expects the configured PostgreSQL database to already contain the `current`, `edge`, `history`, and `tag` tables.
|
||||
72
README.md
Normal file
72
README.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# drill-cloud-v3
|
||||
|
||||
Lightweight Drill Cloud API for an existing PostgreSQL/TimescaleDB database.
|
||||
|
||||
## What is intentionally absent
|
||||
|
||||
- No migrations.
|
||||
- No `tag-translation` module.
|
||||
- No alarm log writes.
|
||||
- No automatic `current`, `edge`, or `tag` upserts during ingest.
|
||||
|
||||
## Database
|
||||
|
||||
The current `cloud` database schema uses these public tables:
|
||||
|
||||
- `edge(id, name, parent_id, tag_ids)`
|
||||
- `tag(id, name, min, max, comment, unit_of_measurement, edge_ids, precision, tag_group)`
|
||||
- `current(id, edge, tag, value, createdAt, updatedAt)`
|
||||
- `history(id, edge, timestamp, tag, value, createdAt)`
|
||||
|
||||
`history` is expected to be a TimescaleDB hypertable partitioned by `timestamp`.
|
||||
Historical chart ranges use continuous aggregates:
|
||||
|
||||
- `history_1m`
|
||||
- `history_5m`
|
||||
- `history_1h`
|
||||
- `history_1d`
|
||||
|
||||
## API
|
||||
|
||||
- `GET /health`
|
||||
- `GET /edge`
|
||||
- `GET /tag?edge=edge5&search=pressure`
|
||||
- `GET /current?edge=edge5&tags=tag1,tag2`
|
||||
- `GET /history?edge=edge5&tags=tag1,tag2&from=2026-06-01T00:00:00Z&to=2026-06-02T00:00:00Z`
|
||||
- `POST /ingest`
|
||||
- `POST /ingest/:edge`
|
||||
|
||||
`POST /ingest` accepts an array of points:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"edge": "edge5",
|
||||
"tag": "hook_weight_1",
|
||||
"time": "2026-06-19T00:00:00Z",
|
||||
"value": 12.34
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
`POST /ingest/:edge` accepts a compact edge payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"time": "2026-06-19T00:00:00Z",
|
||||
"values": {
|
||||
"hook_weight_1": 12.34,
|
||||
"rotary_rpm": 80
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Local Run
|
||||
|
||||
```bash
|
||||
npm install
|
||||
cp .env.example .env
|
||||
npm run start:dev
|
||||
```
|
||||
|
||||
The default port is `3101`.
|
||||
24
docker-compose.yml
Normal file
24
docker-compose.yml
Normal file
@@ -0,0 +1,24 @@
|
||||
services:
|
||||
drill-cloud-v3:
|
||||
container_name: drill-cloud-v3
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: ${PORT:-3101}
|
||||
DATABASE_URL: ${DATABASE_URL}
|
||||
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-}
|
||||
INGEST_API_KEY: ${INGEST_API_KEY:-}
|
||||
PG_POOL_MAX: ${PG_POOL_MAX:-20}
|
||||
INGEST_MAX_BATCH_SIZE: ${INGEST_MAX_BATCH_SIZE:-10000}
|
||||
CURRENT_EVENTS_POLL_MS: ${CURRENT_EVENTS_POLL_MS:-1000}
|
||||
HISTORY_TARGET_POINTS: ${HISTORY_TARGET_POINTS:-2000}
|
||||
HISTORY_MAX_TARGET_POINTS: ${HISTORY_MAX_TARGET_POINTS:-5000}
|
||||
ports:
|
||||
- "${PORT:-3101}:${PORT:-3101}"
|
||||
networks:
|
||||
- proxy
|
||||
|
||||
networks:
|
||||
proxy:
|
||||
external: true
|
||||
24
eslint.config.mjs
Normal file
24
eslint.config.mjs
Normal file
@@ -0,0 +1,24 @@
|
||||
// @ts-check
|
||||
import eslint from '@eslint/js';
|
||||
import globals from 'globals';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: ['dist/**', 'node_modules/**', 'eslint.config.mjs'],
|
||||
},
|
||||
eslint.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
...globals.jest,
|
||||
},
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
8
nest-cli.json
Normal file
8
nest-cli.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
10261
package-lock.json
generated
Normal file
10261
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
58
package.json
Normal file
58
package.json
Normal file
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"name": "drill-cloud-v3",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Lightweight Drill Cloud API for an existing PostgreSQL/TimescaleDB schema.",
|
||||
"license": "PERSONAL",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:prod": "node dist/main.js",
|
||||
"lint": "eslint \"src/**/*.ts\"",
|
||||
"test": "jest --runInBand"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^11.0.1",
|
||||
"@nestjs/config": "^4.0.2",
|
||||
"@nestjs/core": "^11.0.1",
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.2",
|
||||
"compression": "^1.8.1",
|
||||
"pg": "^8.13.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^11.0.0",
|
||||
"@types/compression": "^1.8.1",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/pg": "^8.11.10",
|
||||
"jest": "^30.0.0",
|
||||
"ts-jest": "^29.2.5",
|
||||
"ts-loader": "^9.5.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript-eslint": "^8.20.0",
|
||||
"@eslint/js": "^9.18.0",
|
||||
"eslint": "^9.18.0",
|
||||
"globals": "^16.0.0"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"json",
|
||||
"ts"
|
||||
],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"testEnvironment": "node"
|
||||
}
|
||||
}
|
||||
23
src/app.module.ts
Normal file
23
src/app.module.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { CurrentModule } from './current/current.module';
|
||||
import { DbModule } from './db/db.module';
|
||||
import { EdgeModule } from './edge/edge.module';
|
||||
import { HealthController } from './health.controller';
|
||||
import { HistoryModule } from './history/history.module';
|
||||
import { IngestModule } from './ingest/ingest.module';
|
||||
import { TagModule } from './tag/tag.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({ isGlobal: true }),
|
||||
DbModule,
|
||||
IngestModule,
|
||||
EdgeModule,
|
||||
CurrentModule,
|
||||
HistoryModule,
|
||||
TagModule,
|
||||
],
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class AppModule {}
|
||||
25
src/common/config-number.ts
Normal file
25
src/common/config-number.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
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));
|
||||
}
|
||||
24
src/common/ingest-api-key.guard.ts
Normal file
24
src/common/ingest-api-key.guard.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import type { Request } from 'express';
|
||||
|
||||
@Injectable()
|
||||
export class IngestApiKeyGuard implements CanActivate {
|
||||
constructor(private readonly config: ConfigService) {}
|
||||
|
||||
/** По умолчанию открывает ingest, но проверяет x-api-key, если задан INGEST_API_KEY. */
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const expectedKey = this.config.get<string>('INGEST_API_KEY');
|
||||
if (!expectedKey) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const actualKey = request.header('x-api-key');
|
||||
if (actualKey !== expectedKey) {
|
||||
throw new UnauthorizedException('Invalid ingest API key.');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
22
src/common/normalize-text.ts
Normal file
22
src/common/normalize-text.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
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;
|
||||
}
|
||||
14
src/common/query-list.ts
Normal file
14
src/common/query-list.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
// Поддерживает оба формата: ?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;
|
||||
}
|
||||
49
src/current/current-events.service.ts
Normal file
49
src/current/current-events.service.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { distinctUntilChanged, from, map, Observable, switchMap, timer } from 'rxjs';
|
||||
import { getIntegerConfig } from '../common/config-number';
|
||||
import { CurrentResponseDto } from './dto/current-response.dto';
|
||||
import { GetCurrentDto } from './dto/get-current.dto';
|
||||
import { CurrentService } from './current.service';
|
||||
|
||||
type CurrentSseMessage = {
|
||||
data: CurrentResponseDto;
|
||||
};
|
||||
|
||||
type CurrentSnapshot = {
|
||||
signature: string;
|
||||
response: CurrentResponseDto;
|
||||
};
|
||||
|
||||
const DEFAULT_CURRENT_EVENTS_POLL_MS = 1_000;
|
||||
|
||||
@Injectable()
|
||||
export class CurrentEventsService {
|
||||
constructor(
|
||||
private readonly current: CurrentService,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
/** Отправляет SSE-события current только при изменении снимка данных по edge/tags. */
|
||||
stream(query: GetCurrentDto): Observable<CurrentSseMessage> {
|
||||
const pollMs = Math.max(
|
||||
getIntegerConfig(this.config, 'CURRENT_EVENTS_POLL_MS', DEFAULT_CURRENT_EVENTS_POLL_MS),
|
||||
250,
|
||||
);
|
||||
|
||||
return timer(0, pollMs).pipe(
|
||||
switchMap(() => from(this.createSnapshot(query))),
|
||||
distinctUntilChanged((previous, current) => previous.signature === current.signature),
|
||||
map((snapshot) => ({ data: snapshot.response })),
|
||||
);
|
||||
}
|
||||
|
||||
/** Собирает стабильную подпись ответа, чтобы не слать одинаковые SSE-события повторно. */
|
||||
private async createSnapshot(query: GetCurrentDto): Promise<CurrentSnapshot> {
|
||||
const response = await this.current.findByEdge(query);
|
||||
return {
|
||||
response,
|
||||
signature: JSON.stringify(response),
|
||||
};
|
||||
}
|
||||
}
|
||||
30
src/current/current.controller.ts
Normal file
30
src/current/current.controller.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Controller, Get, Header, Query, Sse } from '@nestjs/common';
|
||||
import { Observable } from 'rxjs';
|
||||
import { CurrentEventsService } from './current-events.service';
|
||||
import { CurrentService } from './current.service';
|
||||
import { CurrentResponseDto } from './dto/current-response.dto';
|
||||
import { GetCurrentDto } from './dto/get-current.dto';
|
||||
|
||||
@Controller('current')
|
||||
export class CurrentController {
|
||||
constructor(
|
||||
private readonly current: CurrentService,
|
||||
private readonly currentEvents: CurrentEventsService,
|
||||
) {}
|
||||
|
||||
/** Возвращает последние известные значения по одному edge с опциональным фильтром тегов. */
|
||||
@Get()
|
||||
@Header('Cache-Control', 'no-store')
|
||||
@Header('Pragma', 'no-cache')
|
||||
@Header('Expires', '0')
|
||||
findByEdge(@Query() query: GetCurrentDto) {
|
||||
return this.current.findByEdge(query);
|
||||
}
|
||||
|
||||
/** Открывает SSE-поток текущих значений по edge с отправкой только изменившихся снимков. */
|
||||
@Sse('events')
|
||||
@Header('Cache-Control', 'no-store')
|
||||
events(@Query() query: GetCurrentDto): Observable<{ data: CurrentResponseDto }> {
|
||||
return this.currentEvents.stream(query);
|
||||
}
|
||||
}
|
||||
24
src/current/current.mapper.ts
Normal file
24
src/current/current.mapper.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { CurrentResponseDto } from './dto/current-response.dto';
|
||||
import { CurrentRow } from './current.types';
|
||||
|
||||
/** Преобразует строки БД current в стабильный DTO для API. */
|
||||
export function createCurrentResponse(edge: string, rows: CurrentRow[]): CurrentResponseDto {
|
||||
return {
|
||||
edge,
|
||||
items: rows.map((row) => ({
|
||||
edge: row.edge,
|
||||
tag: row.tag,
|
||||
value: row.value,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
time: row.updatedAt,
|
||||
name: row.name,
|
||||
tagGroup: row.tag_group,
|
||||
min: row.min,
|
||||
max: row.max,
|
||||
comment: row.comment,
|
||||
unitOfMeasurement: row.unit_of_measurement,
|
||||
precision: row.precision,
|
||||
})),
|
||||
};
|
||||
}
|
||||
11
src/current/current.module.ts
Normal file
11
src/current/current.module.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CurrentController } from './current.controller';
|
||||
import { CurrentEventsService } from './current-events.service';
|
||||
import { CurrentRepository } from './current.repository';
|
||||
import { CurrentService } from './current.service';
|
||||
|
||||
@Module({
|
||||
controllers: [CurrentController],
|
||||
providers: [CurrentService, CurrentEventsService, CurrentRepository],
|
||||
})
|
||||
export class CurrentModule {}
|
||||
38
src/current/current.repository.ts
Normal file
38
src/current/current.repository.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DbService } from '../db/db.service';
|
||||
import { CurrentRow } from './current.types';
|
||||
|
||||
@Injectable()
|
||||
export class CurrentRepository {
|
||||
constructor(private readonly db: DbService) {}
|
||||
|
||||
/** Читает текущие значения и добавляет метаданные тегов для UI-дашбордов. */
|
||||
async findByEdge(edge: string, tags: string[] | null): Promise<CurrentRow[]> {
|
||||
const result = await this.db.query<CurrentRow>(
|
||||
`
|
||||
SELECT
|
||||
c.edge,
|
||||
c.tag,
|
||||
c.value,
|
||||
c."createdAt",
|
||||
c."updatedAt",
|
||||
t.name,
|
||||
t.tag_group,
|
||||
t.min,
|
||||
t.max,
|
||||
t.comment,
|
||||
t.unit_of_measurement,
|
||||
t.precision
|
||||
FROM current AS c
|
||||
LEFT JOIN tag AS t
|
||||
ON t.id = c.tag
|
||||
WHERE c.edge = $1
|
||||
AND ($2::varchar[] IS NULL OR c.tag = ANY($2::varchar[]))
|
||||
ORDER BY c.tag ASC
|
||||
`,
|
||||
[edge, tags],
|
||||
);
|
||||
|
||||
return result.rows;
|
||||
}
|
||||
}
|
||||
19
src/current/current.service.ts
Normal file
19
src/current/current.service.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { normalizeRequiredText } from '../common/normalize-text';
|
||||
import { CurrentResponseDto } from './dto/current-response.dto';
|
||||
import { GetCurrentDto } from './dto/get-current.dto';
|
||||
import { createCurrentResponse } from './current.mapper';
|
||||
import { CurrentRepository } from './current.repository';
|
||||
|
||||
@Injectable()
|
||||
export class CurrentService {
|
||||
constructor(private readonly repository: CurrentRepository) {}
|
||||
|
||||
/** Нормализует фильтры и возвращает текущие значения по одному edge. */
|
||||
async findByEdge(query: GetCurrentDto): Promise<CurrentResponseDto> {
|
||||
const edge = normalizeRequiredText(query.edge, 'edge');
|
||||
const tags = query.tags?.length ? query.tags : null;
|
||||
const rows = await this.repository.findByEdge(edge, tags);
|
||||
return createCurrentResponse(edge, rows);
|
||||
}
|
||||
}
|
||||
14
src/current/current.types.ts
Normal file
14
src/current/current.types.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export type CurrentRow = {
|
||||
edge: string;
|
||||
tag: string;
|
||||
value: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
name: string | null;
|
||||
tag_group: string | null;
|
||||
min: number | null;
|
||||
max: number | null;
|
||||
comment: string | null;
|
||||
unit_of_measurement: string | null;
|
||||
precision: number | null;
|
||||
};
|
||||
20
src/current/dto/current-response.dto.ts
Normal file
20
src/current/dto/current-response.dto.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export type CurrentItemDto = {
|
||||
edge: string;
|
||||
tag: string;
|
||||
value: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
time: Date;
|
||||
name: string | null;
|
||||
tagGroup: string | null;
|
||||
min: number | null;
|
||||
max: number | null;
|
||||
comment: string | null;
|
||||
unitOfMeasurement: string | null;
|
||||
precision: number | null;
|
||||
};
|
||||
|
||||
export type CurrentResponseDto = {
|
||||
edge: string;
|
||||
items: CurrentItemDto[];
|
||||
};
|
||||
15
src/current/dto/get-current.dto.ts
Normal file
15
src/current/dto/get-current.dto.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsArray, IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||
import { parseCommaSeparatedList } from '../../common/query-list';
|
||||
|
||||
export class GetCurrentDto {
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
edge!: string;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => parseCommaSeparatedList(value))
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
tags?: string[];
|
||||
}
|
||||
9
src/db/db.module.ts
Normal file
9
src/db/db.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { DbService } from './db.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [DbService],
|
||||
exports: [DbService],
|
||||
})
|
||||
export class DbModule {}
|
||||
65
src/db/db.service.ts
Normal file
65
src/db/db.service.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Pool, QueryResult, QueryResultRow } from 'pg';
|
||||
import { getIntegerConfig } from '../common/config-number';
|
||||
|
||||
type DatabaseHealth = {
|
||||
now: Date;
|
||||
timescaledb_installed: boolean;
|
||||
timescaledb_version: string | null;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class DbService implements OnModuleInit, OnModuleDestroy {
|
||||
private pool!: Pool;
|
||||
|
||||
constructor(private readonly config: ConfigService) {}
|
||||
|
||||
/** Создает пул PostgreSQL и проверяет БД до начала обработки запросов. */
|
||||
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({
|
||||
connectionString,
|
||||
max: getIntegerConfig(this.config, 'PG_POOL_MAX', 20),
|
||||
idleTimeoutMillis: 30_000,
|
||||
connectionTimeoutMillis: 5_000,
|
||||
statement_timeout: 60_000,
|
||||
query_timeout: 60_000,
|
||||
application_name: 'drill-cloud-v3',
|
||||
});
|
||||
|
||||
await this.health();
|
||||
}
|
||||
|
||||
/** Закрывает пул PostgreSQL при остановке Nest-приложения. */
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
await this.pool?.end();
|
||||
}
|
||||
|
||||
/** Выполняет параметризованный SQL-запрос через общий пул PostgreSQL. */
|
||||
query<T extends QueryResultRow = QueryResultRow>(
|
||||
text: string,
|
||||
values: readonly unknown[] = [],
|
||||
): Promise<QueryResult<T>> {
|
||||
return this.pool.query<T>(text, [...values]);
|
||||
}
|
||||
|
||||
/** Возвращает легкий health-снимок БД, включая статус расширения TimescaleDB. */
|
||||
async health(): Promise<DatabaseHealth> {
|
||||
const result = await this.query<DatabaseHealth>(`
|
||||
SELECT
|
||||
now(),
|
||||
ext.extversion IS NOT NULL AS timescaledb_installed,
|
||||
ext.extversion AS timescaledb_version
|
||||
FROM (SELECT 1) AS probe
|
||||
LEFT JOIN pg_extension AS ext
|
||||
ON ext.extname = 'timescaledb'
|
||||
`);
|
||||
|
||||
return result.rows[0];
|
||||
}
|
||||
}
|
||||
14
src/edge/dto/edge-response.dto.ts
Normal file
14
src/edge/dto/edge-response.dto.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export type EdgeItemDto = {
|
||||
id: string;
|
||||
name: string;
|
||||
parentId: string | null;
|
||||
tagIds: string[];
|
||||
tagCount: number;
|
||||
currentTagCount: number;
|
||||
liveTagCount: number;
|
||||
lastDataAt: Date | null;
|
||||
};
|
||||
|
||||
export type EdgeResponseDto = {
|
||||
items: EdgeItemDto[];
|
||||
};
|
||||
15
src/edge/dto/get-edges.dto.ts
Normal file
15
src/edge/dto/get-edges.dto.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsOptional, IsString } from 'class-validator';
|
||||
import { normalizeOptionalText } from '../../common/normalize-text';
|
||||
|
||||
export class GetEdgesDto {
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => normalizeOptionalText(value))
|
||||
@IsString()
|
||||
parentId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => normalizeOptionalText(value))
|
||||
@IsString()
|
||||
search?: string;
|
||||
}
|
||||
17
src/edge/edge.controller.ts
Normal file
17
src/edge/edge.controller.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Controller, Get, Header, Query } from '@nestjs/common';
|
||||
import { GetEdgesDto } from './dto/get-edges.dto';
|
||||
import { EdgeService } from './edge.service';
|
||||
|
||||
@Controller('edge')
|
||||
export class EdgeController {
|
||||
constructor(private readonly edge: EdgeService) {}
|
||||
|
||||
/** Возвращает список edge с опциональными фильтрами по parent и тексту. */
|
||||
@Get()
|
||||
@Header('Cache-Control', 'no-store')
|
||||
@Header('Pragma', 'no-cache')
|
||||
@Header('Expires', '0')
|
||||
findAll(@Query() query: GetEdgesDto) {
|
||||
return this.edge.findAll(query);
|
||||
}
|
||||
}
|
||||
18
src/edge/edge.mapper.ts
Normal file
18
src/edge/edge.mapper.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { EdgeResponseDto } from './dto/edge-response.dto';
|
||||
import { EdgeRow } from './edge.types';
|
||||
|
||||
/** Преобразует строки БД edge в стабильный DTO для API. */
|
||||
export function createEdgeResponse(rows: EdgeRow[]): EdgeResponseDto {
|
||||
return {
|
||||
items: rows.map((row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
10
src/edge/edge.module.ts
Normal file
10
src/edge/edge.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { EdgeController } from './edge.controller';
|
||||
import { EdgeRepository } from './edge.repository';
|
||||
import { EdgeService } from './edge.service';
|
||||
|
||||
@Module({
|
||||
controllers: [EdgeController],
|
||||
providers: [EdgeService, EdgeRepository],
|
||||
})
|
||||
export class EdgeModule {}
|
||||
42
src/edge/edge.repository.ts
Normal file
42
src/edge/edge.repository.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DbService } from '../db/db.service';
|
||||
import { EdgeRow } from './edge.types';
|
||||
|
||||
@Injectable()
|
||||
export class EdgeRepository {
|
||||
constructor(private readonly db: DbService) {}
|
||||
|
||||
/** Читает каталог edge с легкими счетчиками свежести из текущих значений. */
|
||||
async findAll(parentId: string | null, search: string | null): Promise<EdgeRow[]> {
|
||||
const result = await this.db.query<EdgeRow>(
|
||||
`
|
||||
SELECT
|
||||
e.id,
|
||||
e.name,
|
||||
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
|
||||
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)
|
||||
AND (
|
||||
$2::text IS NULL
|
||||
OR e.id 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
|
||||
`,
|
||||
[parentId, search],
|
||||
);
|
||||
|
||||
return result.rows;
|
||||
}
|
||||
}
|
||||
18
src/edge/edge.service.ts
Normal file
18
src/edge/edge.service.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { EdgeResponseDto } from './dto/edge-response.dto';
|
||||
import { GetEdgesDto } from './dto/get-edges.dto';
|
||||
import { createEdgeResponse } from './edge.mapper';
|
||||
import { EdgeRepository } from './edge.repository';
|
||||
|
||||
@Injectable()
|
||||
export class EdgeService {
|
||||
constructor(private readonly repository: EdgeRepository) {}
|
||||
|
||||
/** Нормализует фильтры и возвращает каталог edge для UI. */
|
||||
async findAll(query: GetEdgesDto = {}): Promise<EdgeResponseDto> {
|
||||
const parentId = query.parentId ?? null;
|
||||
const search = query.search ? `%${query.search}%` : null;
|
||||
const rows = await this.repository.findAll(parentId, search);
|
||||
return createEdgeResponse(rows);
|
||||
}
|
||||
}
|
||||
10
src/edge/edge.types.ts
Normal file
10
src/edge/edge.types.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export type EdgeRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
parent_id: string | null;
|
||||
tag_ids: string[];
|
||||
tag_count: number;
|
||||
current_tag_count: number;
|
||||
live_tag_count: number;
|
||||
last_data_at: Date | null;
|
||||
};
|
||||
17
src/health.controller.ts
Normal file
17
src/health.controller.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { DbService } from './db/db.service';
|
||||
|
||||
@Controller()
|
||||
export class HealthController {
|
||||
constructor(private readonly db: DbService) {}
|
||||
|
||||
/** Проверяет доступность API и базы данных для деплоя и проверок здоровья. */
|
||||
@Get('health')
|
||||
async health() {
|
||||
const database = await this.db.health();
|
||||
return {
|
||||
status: 'ok',
|
||||
database,
|
||||
};
|
||||
}
|
||||
}
|
||||
41
src/history/dto/get-history.dto.ts
Normal file
41
src/history/dto/get-history.dto.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
IsArray,
|
||||
IsDateString,
|
||||
IsNotEmpty,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
import { parseCommaSeparatedList } from '../../common/query-list';
|
||||
|
||||
export class GetHistoryDto {
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
edge!: string;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => parseCommaSeparatedList(value))
|
||||
@IsArray()
|
||||
@ArrayMaxSize(500)
|
||||
@IsString({ each: true })
|
||||
tags?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
from?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
to?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(100)
|
||||
@Max(5000)
|
||||
targetPoints?: number;
|
||||
}
|
||||
26
src/history/dto/history-response.dto.ts
Normal file
26
src/history/dto/history-response.dto.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export type HistoryPointDto = {
|
||||
t: number;
|
||||
v: number;
|
||||
min: number;
|
||||
avg: number;
|
||||
max: 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 = {
|
||||
edge: string;
|
||||
source: HistoryResponseSource;
|
||||
targetPoints: number;
|
||||
series: HistorySeriesDto[];
|
||||
from?: Date;
|
||||
to?: Date;
|
||||
resolutionSeconds?: number | null;
|
||||
};
|
||||
30
src/history/history-source.ts
Normal file
30
src/history/history-source.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
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]
|
||||
);
|
||||
}
|
||||
17
src/history/history.controller.ts
Normal file
17
src/history/history.controller.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Controller, Get, Header, Query } from '@nestjs/common';
|
||||
import { GetHistoryDto } from './dto/get-history.dto';
|
||||
import { HistoryService } from './history.service';
|
||||
|
||||
@Controller('history')
|
||||
export class HistoryController {
|
||||
constructor(private readonly history: HistoryService) {}
|
||||
|
||||
/** Возвращает серии истории для графиков по одному edge: последние значения или сырой диапазон. */
|
||||
@Get()
|
||||
@Header('Cache-Control', 'no-store')
|
||||
@Header('Pragma', 'no-cache')
|
||||
@Header('Expires', '0')
|
||||
findSeries(@Query() query: GetHistoryDto) {
|
||||
return this.history.findSeries(query);
|
||||
}
|
||||
}
|
||||
55
src/history/history.mapper.ts
Normal file
55
src/history/history.mapper.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
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());
|
||||
}
|
||||
10
src/history/history.module.ts
Normal file
10
src/history/history.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HistoryController } from './history.controller';
|
||||
import { HistoryRepository } from './history.repository';
|
||||
import { HistoryService } from './history.service';
|
||||
|
||||
@Module({
|
||||
controllers: [HistoryController],
|
||||
providers: [HistoryRepository, HistoryService],
|
||||
})
|
||||
export class HistoryModule {}
|
||||
160
src/history/history.repository.ts
Normal file
160
src/history/history.repository.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DbService } from '../db/db.service';
|
||||
import { HistoryRow, HistorySource } from './history.types';
|
||||
|
||||
@Injectable()
|
||||
export class HistoryRepository {
|
||||
constructor(private readonly db: DbService) {}
|
||||
|
||||
/** Загружает последние N сырых точек истории для каждого запрошенного тега. */
|
||||
async findLatest(edge: string, tags: string[], targetPoints: number): Promise<HistoryRow[]> {
|
||||
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,
|
||||
tags: string[],
|
||||
from: Date,
|
||||
to: Date,
|
||||
targetPoints: number,
|
||||
): Promise<HistoryRow[]> {
|
||||
const bucketMillis = this.getBucketMillis(from, to, targetPoints);
|
||||
const fromMillis = from.getTime();
|
||||
const result = await this.db.query<HistoryRow>(
|
||||
`
|
||||
WITH filtered AS (
|
||||
SELECT
|
||||
edge,
|
||||
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,
|
||||
avg(value)::double precision AS avg_value,
|
||||
max(value)::double precision AS max_value,
|
||||
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
|
||||
WHERE edge = $1
|
||||
ORDER BY tag ASC
|
||||
`,
|
||||
[edge],
|
||||
);
|
||||
|
||||
return distinct.rows.map((row) => row.tag);
|
||||
}
|
||||
}
|
||||
83
src/history/history.service.ts
Normal file
83
src/history/history.service.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
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 { HistoryResponseDto } from './dto/history-response.dto';
|
||||
import { chooseHistorySource } from './history-source';
|
||||
import { createHistoryResponse } from './history.mapper';
|
||||
import { HistoryRepository } from './history.repository';
|
||||
|
||||
@Injectable()
|
||||
export class HistoryService {
|
||||
constructor(
|
||||
private readonly repository: HistoryRepository,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
/** Определяет теги, выбирает слой истории и собирает API-ответ для графика. */
|
||||
async findSeries(query: GetHistoryDto): Promise<HistoryResponseDto> {
|
||||
const edge = normalizeRequiredText(query.edge, 'edge');
|
||||
const targetPoints = this.getTargetPoints(query.targetPoints);
|
||||
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())) {
|
||||
throw new BadRequestException('Invalid from/to date.');
|
||||
}
|
||||
|
||||
if (from > to) {
|
||||
throw new BadRequestException('from cannot be greater than to.');
|
||||
}
|
||||
|
||||
return { from, to };
|
||||
}
|
||||
|
||||
/** Применяет лимиты точек по умолчанию и максимуму, чтобы ответ истории был удобным для графиков. */
|
||||
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);
|
||||
}
|
||||
}
|
||||
19
src/history/history.types.ts
Normal file
19
src/history/history.types.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export type HistorySourceName = 'raw' | '1m' | '5m' | '1h' | '1d';
|
||||
|
||||
export type HistorySource = {
|
||||
name: HistorySourceName;
|
||||
table: string;
|
||||
timeColumn: string;
|
||||
intervalSeconds: number;
|
||||
};
|
||||
|
||||
export type HistoryRow = {
|
||||
edge: string;
|
||||
tag: string;
|
||||
time: Date;
|
||||
value: number;
|
||||
min_value: number;
|
||||
avg_value: number;
|
||||
max_value: number;
|
||||
point_count: number;
|
||||
};
|
||||
16
src/ingest/dto/ingest-edge-values.dto.ts
Normal file
16
src/ingest/dto/ingest-edge-values.dto.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsDateString, IsNumber, IsObject, IsOptional } from 'class-validator';
|
||||
|
||||
export class IngestEdgeValuesDto {
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
time?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
timestamp?: number;
|
||||
|
||||
@IsObject()
|
||||
values!: Record<string, number>;
|
||||
}
|
||||
25
src/ingest/dto/ingest-point.dto.ts
Normal file
25
src/ingest/dto/ingest-point.dto.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsDateString, IsNotEmpty, IsNumber, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class IngestPointDto {
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
edge!: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
tag!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
time?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
timestamp?: number;
|
||||
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
value!: number;
|
||||
}
|
||||
34
src/ingest/ingest.controller.ts
Normal file
34
src/ingest/ingest.controller.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Body, Controller, Param, ParseArrayPipe, Post, UseGuards } from '@nestjs/common';
|
||||
import { IngestApiKeyGuard } from '../common/ingest-api-key.guard';
|
||||
import { IngestEdgeValuesDto } from './dto/ingest-edge-values.dto';
|
||||
import { IngestPointDto } from './dto/ingest-point.dto';
|
||||
import { IngestService } from './ingest.service';
|
||||
|
||||
@Controller('ingest')
|
||||
@UseGuards(IngestApiKeyGuard)
|
||||
export class IngestController {
|
||||
constructor(private readonly ingest: IngestService) {}
|
||||
|
||||
/** Принимает массив сырых точек и сохраняет их как записи истории. */
|
||||
@Post()
|
||||
ingestPoints(
|
||||
@Body(new ParseArrayPipe({ items: IngestPointDto }))
|
||||
points: IngestPointDto[],
|
||||
) {
|
||||
return this.ingest.ingestPoints(points);
|
||||
}
|
||||
|
||||
/** Принимает компактное тело запроса по edge и разворачивает каждое значение в точку истории. */
|
||||
@Post(':edge')
|
||||
ingestEdgeValues(@Param('edge') edge: string, @Body() body: IngestEdgeValuesDto) {
|
||||
const points = Object.entries(body.values).map(([tag, value]) => ({
|
||||
edge,
|
||||
tag,
|
||||
value: Number(value),
|
||||
time: body.time,
|
||||
timestamp: body.timestamp,
|
||||
}));
|
||||
|
||||
return this.ingest.ingestPoints(points);
|
||||
}
|
||||
}
|
||||
10
src/ingest/ingest.module.ts
Normal file
10
src/ingest/ingest.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { IngestApiKeyGuard } from '../common/ingest-api-key.guard';
|
||||
import { IngestController } from './ingest.controller';
|
||||
import { IngestService } from './ingest.service';
|
||||
|
||||
@Module({
|
||||
controllers: [IngestController],
|
||||
providers: [IngestService, IngestApiKeyGuard],
|
||||
})
|
||||
export class IngestModule {}
|
||||
108
src/ingest/ingest.service.ts
Normal file
108
src/ingest/ingest.service.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { BadRequestException, 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 { 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 = {
|
||||
processed: number;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class IngestService {
|
||||
constructor(
|
||||
private readonly db: DbService,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
/** Проверяет батч и записывает сырые точки в существующую таблицу history. */
|
||||
async ingestPoints(points: IngestPointDto[]): Promise<IngestResult> {
|
||||
if (!points.length) {
|
||||
return { processed: 0 };
|
||||
}
|
||||
|
||||
this.assertBatchSize(points.length);
|
||||
|
||||
const normalized = points.map((point) => this.normalizePoint(point));
|
||||
|
||||
await this.db.query(
|
||||
`
|
||||
INSERT INTO history (timestamp, edge, tag, value)
|
||||
SELECT time, edge, tag, value
|
||||
FROM unnest(
|
||||
$1::timestamp[],
|
||||
$2::varchar[],
|
||||
$3::varchar[],
|
||||
$4::double precision[]
|
||||
) AS point(time, edge, tag, 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 };
|
||||
}
|
||||
|
||||
/** Защищает API от слишком больших ingest-запросов, занимающих соединение с БД надолго. */
|
||||
private assertBatchSize(size: number): void {
|
||||
const maxBatchSize = Math.max(
|
||||
getIntegerConfig(this.config, 'INGEST_MAX_BATCH_SIZE', DEFAULT_MAX_BATCH_SIZE),
|
||||
1,
|
||||
);
|
||||
|
||||
if (size > maxBatchSize) {
|
||||
throw new BadRequestException(`Batch size must not exceed ${maxBatchSize} points.`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Приводит входной DTO к нормализованному виду для вставки в history. */
|
||||
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.');
|
||||
}
|
||||
}
|
||||
33
src/main.ts
Normal file
33
src/main.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import compression from 'compression';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
/** Запускает HTTP API со сжатием, CORS и глобальной валидацией DTO. */
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
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({
|
||||
origin: corsAllowedOrigins,
|
||||
credentials: true,
|
||||
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization', 'x-api-key'],
|
||||
});
|
||||
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
transform: true,
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
}),
|
||||
);
|
||||
|
||||
await app.listen(process.env.PORT ?? 3101);
|
||||
}
|
||||
|
||||
void bootstrap();
|
||||
15
src/tag/dto/get-tags.dto.ts
Normal file
15
src/tag/dto/get-tags.dto.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsOptional, IsString } from 'class-validator';
|
||||
import { normalizeOptionalText } from '../../common/normalize-text';
|
||||
|
||||
export class GetTagsDto {
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => normalizeOptionalText(value))
|
||||
@IsString()
|
||||
edge?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => normalizeOptionalText(value))
|
||||
@IsString()
|
||||
search?: string;
|
||||
}
|
||||
15
src/tag/dto/tag-response.dto.ts
Normal file
15
src/tag/dto/tag-response.dto.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export type TagItemDto = {
|
||||
id: string;
|
||||
name: string;
|
||||
tagGroup: string | null;
|
||||
min: number;
|
||||
max: number;
|
||||
comment: string;
|
||||
unitOfMeasurement: string;
|
||||
edgeIds: string[];
|
||||
precision: number | null;
|
||||
};
|
||||
|
||||
export type TagResponseDto = {
|
||||
items: TagItemDto[];
|
||||
};
|
||||
17
src/tag/tag.controller.ts
Normal file
17
src/tag/tag.controller.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Controller, Get, Header, Query } from '@nestjs/common';
|
||||
import { GetTagsDto } from './dto/get-tags.dto';
|
||||
import { TagService } from './tag.service';
|
||||
|
||||
@Controller('tag')
|
||||
export class TagController {
|
||||
constructor(private readonly tag: TagService) {}
|
||||
|
||||
/** Возвращает список тегов с опциональными фильтрами по edge и тексту. */
|
||||
@Get()
|
||||
@Header('Cache-Control', 'no-store')
|
||||
@Header('Pragma', 'no-cache')
|
||||
@Header('Expires', '0')
|
||||
findAll(@Query() query: GetTagsDto) {
|
||||
return this.tag.findAll(query);
|
||||
}
|
||||
}
|
||||
19
src/tag/tag.mapper.ts
Normal file
19
src/tag/tag.mapper.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { TagResponseDto } from './dto/tag-response.dto';
|
||||
import { TagRow } from './tag.types';
|
||||
|
||||
/** Преобразует строки БД tag в стабильный DTO для API. */
|
||||
export function createTagResponse(rows: TagRow[]): TagResponseDto {
|
||||
return {
|
||||
items: rows.map((row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
tagGroup: row.tag_group,
|
||||
min: row.min,
|
||||
max: row.max,
|
||||
comment: row.comment,
|
||||
unitOfMeasurement: row.unit_of_measurement,
|
||||
edgeIds: row.edge_ids ?? [],
|
||||
precision: row.precision,
|
||||
})),
|
||||
};
|
||||
}
|
||||
10
src/tag/tag.module.ts
Normal file
10
src/tag/tag.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TagController } from './tag.controller';
|
||||
import { TagRepository } from './tag.repository';
|
||||
import { TagService } from './tag.service';
|
||||
|
||||
@Module({
|
||||
controllers: [TagController],
|
||||
providers: [TagService, TagRepository],
|
||||
})
|
||||
export class TagModule {}
|
||||
50
src/tag/tag.repository.ts
Normal file
50
src/tag/tag.repository.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DbService } from '../db/db.service';
|
||||
import { TagRow } from './tag.types';
|
||||
|
||||
@Injectable()
|
||||
export class TagRepository {
|
||||
constructor(private readonly db: DbService) {}
|
||||
|
||||
/** Читает метаданные тегов с учетом связей из tag.edge_ids и edge.tag_ids. */
|
||||
async findAll(edge: string | null, search: string | null): Promise<TagRow[]> {
|
||||
const result = await this.db.query<TagRow>(
|
||||
`
|
||||
SELECT
|
||||
t.id,
|
||||
t.name,
|
||||
t.tag_group,
|
||||
t.min,
|
||||
t.max,
|
||||
t.comment,
|
||||
t.unit_of_measurement,
|
||||
t.edge_ids,
|
||||
t.precision
|
||||
FROM tag AS t
|
||||
WHERE (
|
||||
$1::varchar IS NULL
|
||||
OR $1::varchar = ANY(t.edge_ids)
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
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
|
||||
NULLIF(t.tag_group, '') ASC NULLS LAST,
|
||||
t.name ASC,
|
||||
t.id ASC
|
||||
`,
|
||||
[edge, search],
|
||||
);
|
||||
|
||||
return result.rows;
|
||||
}
|
||||
}
|
||||
18
src/tag/tag.service.ts
Normal file
18
src/tag/tag.service.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { GetTagsDto } from './dto/get-tags.dto';
|
||||
import { TagResponseDto } from './dto/tag-response.dto';
|
||||
import { createTagResponse } from './tag.mapper';
|
||||
import { TagRepository } from './tag.repository';
|
||||
|
||||
@Injectable()
|
||||
export class TagService {
|
||||
constructor(private readonly repository: TagRepository) {}
|
||||
|
||||
/** Нормализует фильтры и возвращает метаданные тегов для UI. */
|
||||
async findAll(query: GetTagsDto): Promise<TagResponseDto> {
|
||||
const edge = query.edge ?? null;
|
||||
const search = query.search ? `%${query.search}%` : null;
|
||||
const rows = await this.repository.findAll(edge, search);
|
||||
return createTagResponse(rows);
|
||||
}
|
||||
}
|
||||
11
src/tag/tag.types.ts
Normal file
11
src/tag/tag.types.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export type TagRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
tag_group: string | null;
|
||||
min: number;
|
||||
max: number;
|
||||
comment: string;
|
||||
unit_of_measurement: string;
|
||||
edge_ids: string[];
|
||||
precision: number | null;
|
||||
};
|
||||
4
tsconfig.build.json
Normal file
4
tsconfig.build.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "dist", "test", "**/*spec.ts"]
|
||||
}
|
||||
21
tsconfig.json
Normal file
21
tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node",
|
||||
"esModuleInterop": true,
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2023",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user