first commit
This commit is contained in:
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;
|
||||
};
|
||||
Reference in New Issue
Block a user