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

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