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