first commit

This commit is contained in:
Первов Артем
2026-06-19 08:18:52 +03:00
commit 05a9e0e535
61 changed files with 11950 additions and 0 deletions

23
src/app.module.ts Normal file
View 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 {}

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

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

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

View 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),
};
}
}

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

View 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,
})),
};
}

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

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

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

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

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

View 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
View 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
View 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];
}
}

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

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

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

View 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
View 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
View 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
View 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,
};
}
}

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

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

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

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

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

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

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

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

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

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

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

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

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

View 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
View 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();

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

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