Compare commits
18 Commits
25b5eaea55
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2b6802c5e1 | |||
| 0859a7a381 | |||
|
|
b2d3bbddfc | ||
| 54c8bf2e01 | |||
|
|
22529ba0e8 | ||
| 387f6c0613 | |||
|
|
203bdf193e | ||
|
|
3bf4cebbe6 | ||
|
|
907c24c45b | ||
|
|
b057ea154a | ||
|
|
0e2a7eeeec | ||
|
|
c702946393 | ||
|
|
5260b8a9c5 | ||
|
|
30bd7a0d28 | ||
| 32ea0d7c4d | |||
| c22d1b5b8c | |||
| 56e90a54d8 | |||
| 15fe566d53 |
@@ -1,10 +1,9 @@
|
|||||||
PORT=3101
|
PORT=3101
|
||||||
branch=
|
branch=
|
||||||
DATABASE_URL=postgres://greact:pG3526l4@194.36.208.86:5433/cloud
|
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>.
|
# Optional. When set, POST /api/ingest requires x-api-key: <value>.
|
||||||
INGEST_API_KEY=
|
INGEST_API_KEY=
|
||||||
|
|
||||||
# Poll interval for GET /current/events SSE snapshots.
|
# Poll interval for GET /api/current/events SSE snapshots.
|
||||||
CURRENT_EVENTS_POLL_MS=1000
|
CURRENT_EVENTS_POLL_MS=1000
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
PORT=3101
|
PORT=3101
|
||||||
DATABASE_URL=postgres://greact:pG3526l4@194.36.208.86:5433/cloud
|
DATABASE_URL=postgres://greact:pG3526l4@194.36.208.86:5433/cloud
|
||||||
CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173
|
|
||||||
INGEST_API_KEY=
|
INGEST_API_KEY=
|
||||||
CURRENT_EVENTS_POLL_MS=1000
|
CURRENT_EVENTS_POLL_MS=1000
|
||||||
|
|||||||
20
README.md
20
README.md
@@ -28,15 +28,17 @@ Historical chart ranges use continuous aggregates:
|
|||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
- `GET /health`
|
All routes are served under the `/api` prefix.
|
||||||
- `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 one point:
|
- `GET /api/health`
|
||||||
|
- `GET /api/edge`
|
||||||
|
- `GET /api/tag?edge=edge5&search=pressure`
|
||||||
|
- `GET /api/current?edge=edge5&tags=tag1,tag2`
|
||||||
|
- `GET /api/history?edge=edge5&tags=tag1,tag2&from=2026-06-01T00:00:00Z&to=2026-06-02T00:00:00Z`
|
||||||
|
- `POST /api/ingest`
|
||||||
|
- `POST /api/ingest/:edge`
|
||||||
|
|
||||||
|
`POST /api/ingest` accepts one point:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -47,7 +49,7 @@ Historical chart ranges use continuous aggregates:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`POST /ingest/:edge` accepts a compact edge payload:
|
`POST /api/ingest/:edge` accepts a compact edge payload:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
|||||||
19
docker-compose.dev.yml
Normal file
19
docker-compose.dev.yml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
services:
|
||||||
|
drill-cloud-v3-dev:
|
||||||
|
container_name: container-drill-cloud-v3${branch:+-${branch}}
|
||||||
|
build: .
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
PORT: ${PORT}
|
||||||
|
DATABASE_URL: ${DATABASE_URL}
|
||||||
|
INGEST_API_KEY: ${INGEST_API_KEY}
|
||||||
|
CURRENT_EVENTS_POLL_MS: ${CURRENT_EVENTS_POLL_MS}
|
||||||
|
expose:
|
||||||
|
- "3101"
|
||||||
|
networks:
|
||||||
|
- proxy
|
||||||
|
|
||||||
|
networks:
|
||||||
|
proxy:
|
||||||
|
external: true
|
||||||
@@ -1,13 +1,12 @@
|
|||||||
services:
|
services:
|
||||||
drill-cloud-v3:
|
drill-cloud-v3:
|
||||||
container_name: drill-cloud-v3${branch:+-${branch}}
|
container_name: container-drill-cloud-v3${branch:+-${branch}}
|
||||||
build: .
|
build: .
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
PORT: ${PORT}
|
PORT: ${PORT}
|
||||||
DATABASE_URL: ${DATABASE_URL}
|
DATABASE_URL: ${DATABASE_URL}
|
||||||
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS}
|
|
||||||
INGEST_API_KEY: ${INGEST_API_KEY}
|
INGEST_API_KEY: ${INGEST_API_KEY}
|
||||||
CURRENT_EVENTS_POLL_MS: ${CURRENT_EVENTS_POLL_MS}
|
CURRENT_EVENTS_POLL_MS: ${CURRENT_EVENTS_POLL_MS}
|
||||||
expose:
|
expose:
|
||||||
|
|||||||
8
migrations/0001_camera.sql
Normal file
8
migrations/0001_camera.sql
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS camera (
|
||||||
|
edge varchar NOT NULL,
|
||||||
|
protocol varchar NOT NULL,
|
||||||
|
source varchar NOT NULL,
|
||||||
|
PRIMARY KEY (edge, protocol, source)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS camera_edge_idx ON camera (edge);
|
||||||
2
migrations/0002_current_value_nullable.sql
Normal file
2
migrations/0002_current_value_nullable.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE current
|
||||||
|
ALTER COLUMN value DROP NOT NULL;
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { CameraModule } from './camera/camera.module';
|
||||||
import { CurrentModule } from './current/current.module';
|
import { CurrentModule } from './current/current.module';
|
||||||
import { DbModule } from './db/db.module';
|
import { DbModule } from './db/db.module';
|
||||||
import { EdgeModule } from './edge/edge.module';
|
import { EdgeModule } from './edge/edge.module';
|
||||||
@@ -10,6 +11,7 @@ import { TagModule } from './tag/tag.module';
|
|||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
DbModule,
|
DbModule,
|
||||||
|
CameraModule,
|
||||||
IngestModule,
|
IngestModule,
|
||||||
EdgeModule,
|
EdgeModule,
|
||||||
CurrentModule,
|
CurrentModule,
|
||||||
|
|||||||
16
src/camera/camera.controller.ts
Normal file
16
src/camera/camera.controller.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { Controller, Get, Header, Query } from '@nestjs/common';
|
||||||
|
import { CameraService } from './camera.service';
|
||||||
|
import { GetCamerasDto } from './dto/get-cameras.dto';
|
||||||
|
|
||||||
|
@Controller('camera')
|
||||||
|
export class CameraController {
|
||||||
|
constructor(private readonly camera: CameraService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@Header('Cache-Control', 'no-store')
|
||||||
|
@Header('Pragma', 'no-cache')
|
||||||
|
@Header('Expires', '0')
|
||||||
|
findByEdge(@Query() query: GetCamerasDto) {
|
||||||
|
return this.camera.findByEdge(query);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/camera/camera.mapper.ts
Normal file
12
src/camera/camera.mapper.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { CameraResponseDto } from './dto/camera-response.dto';
|
||||||
|
import { CameraRow } from './camera.types';
|
||||||
|
|
||||||
|
export function createCameraResponse(edge: string, rows: CameraRow[]): CameraResponseDto {
|
||||||
|
return {
|
||||||
|
edge,
|
||||||
|
items: rows.map((row) => ({
|
||||||
|
protocol: row.protocol,
|
||||||
|
source: row.source,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
10
src/camera/camera.module.ts
Normal file
10
src/camera/camera.module.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { CameraController } from './camera.controller';
|
||||||
|
import { CameraRepository } from './camera.repository';
|
||||||
|
import { CameraService } from './camera.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [CameraController],
|
||||||
|
providers: [CameraService, CameraRepository],
|
||||||
|
})
|
||||||
|
export class CameraModule {}
|
||||||
22
src/camera/camera.repository.ts
Normal file
22
src/camera/camera.repository.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { DbService } from '../db/db.service';
|
||||||
|
import { CameraRow } from './camera.types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CameraRepository {
|
||||||
|
constructor(private readonly db: DbService) {}
|
||||||
|
|
||||||
|
async findByEdge(edge: string): Promise<CameraRow[]> {
|
||||||
|
const result = await this.db.query<CameraRow>(
|
||||||
|
`
|
||||||
|
SELECT protocol, source
|
||||||
|
FROM camera
|
||||||
|
WHERE edge = $1
|
||||||
|
ORDER BY source ASC
|
||||||
|
`,
|
||||||
|
[edge],
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows;
|
||||||
|
}
|
||||||
|
}
|
||||||
15
src/camera/camera.service.ts
Normal file
15
src/camera/camera.service.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { CameraResponseDto } from './dto/camera-response.dto';
|
||||||
|
import { GetCamerasDto } from './dto/get-cameras.dto';
|
||||||
|
import { createCameraResponse } from './camera.mapper';
|
||||||
|
import { CameraRepository } from './camera.repository';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CameraService {
|
||||||
|
constructor(private readonly repository: CameraRepository) {}
|
||||||
|
|
||||||
|
async findByEdge(query: GetCamerasDto): Promise<CameraResponseDto> {
|
||||||
|
const rows = await this.repository.findByEdge(query.edge);
|
||||||
|
return createCameraResponse(query.edge, rows);
|
||||||
|
}
|
||||||
|
}
|
||||||
4
src/camera/camera.types.ts
Normal file
4
src/camera/camera.types.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export type CameraRow = {
|
||||||
|
protocol: string;
|
||||||
|
source: string;
|
||||||
|
};
|
||||||
9
src/camera/dto/camera-response.dto.ts
Normal file
9
src/camera/dto/camera-response.dto.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export type CameraItemDto = {
|
||||||
|
protocol: string;
|
||||||
|
source: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CameraResponseDto = {
|
||||||
|
edge: string;
|
||||||
|
items: CameraItemDto[];
|
||||||
|
};
|
||||||
7
src/camera/dto/get-cameras.dto.ts
Normal file
7
src/camera/dto/get-cameras.dto.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { IsNotEmpty, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class GetCamerasDto {
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
edge!: string;
|
||||||
|
}
|
||||||
11
src/common/is-nullable-number.decorator.ts
Normal file
11
src/common/is-nullable-number.decorator.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { applyDecorators } from '@nestjs/common';
|
||||||
|
import { Transform } from 'class-transformer';
|
||||||
|
import { IsNumber, ValidateIf, ValidationOptions } from 'class-validator';
|
||||||
|
|
||||||
|
export function IsNullableNumber(options?: ValidationOptions): PropertyDecorator {
|
||||||
|
return applyDecorators(
|
||||||
|
Transform(({ value }) => (value === null ? null : Number(value))),
|
||||||
|
ValidateIf((_, value) => value !== null),
|
||||||
|
IsNumber({}, options),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
export type CurrentRow = {
|
export type CurrentRow = {
|
||||||
edge: string;
|
edge: string;
|
||||||
tag: string;
|
tag: string;
|
||||||
value: number;
|
value: number | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export type CurrentItemDto = {
|
export type CurrentItemDto = {
|
||||||
edge: string;
|
edge: string;
|
||||||
tag: string;
|
tag: string;
|
||||||
value: number;
|
value: number | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
time: Date;
|
time: Date;
|
||||||
|
|||||||
@@ -5,5 +5,5 @@ export class IngestEdgeValuesDto {
|
|||||||
timestamp!: string;
|
timestamp!: string;
|
||||||
|
|
||||||
@IsObject()
|
@IsObject()
|
||||||
values!: Record<string, number>;
|
values!: Record<string, number | null>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Type } from 'class-transformer';
|
import { IsDateString, IsNotEmpty, IsString } from 'class-validator';
|
||||||
import { IsDateString, IsNotEmpty, IsNumber, IsString } from 'class-validator';
|
import { IsNullableNumber } from '../../common/is-nullable-number.decorator';
|
||||||
|
|
||||||
export class IngestPointDto {
|
export class IngestPointDto {
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@@ -13,7 +13,6 @@ export class IngestPointDto {
|
|||||||
@IsDateString()
|
@IsDateString()
|
||||||
timestamp!: string;
|
timestamp!: string;
|
||||||
|
|
||||||
@Type(() => Number)
|
@IsNullableNumber()
|
||||||
@IsNumber()
|
value!: number | null;
|
||||||
value!: number;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,19 +9,20 @@ import { IngestService } from './ingest.service';
|
|||||||
export class IngestController {
|
export class IngestController {
|
||||||
constructor(private readonly ingest: IngestService) {}
|
constructor(private readonly ingest: IngestService) {}
|
||||||
|
|
||||||
/** Принимает одну сырую точку и сохраняет ее как запись истории/current. */
|
|
||||||
@Post()
|
@Post()
|
||||||
ingestPoint(@Body() point: IngestPointDto) {
|
ingestPoint(@Body() point: IngestPointDto) {
|
||||||
return this.ingest.ingestPoint(point);
|
return this.ingest.ingestPoint(point);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Принимает компактное тело запроса по edge и сохраняет каждое значение построчно. */
|
|
||||||
@Post(':edge')
|
@Post(':edge')
|
||||||
ingestEdgeValues(@Param('edge') edge: string, @Body() body: IngestEdgeValuesDto) {
|
ingestEdgeValues(
|
||||||
|
@Param('edge') edge: string,
|
||||||
|
@Body() body: IngestEdgeValuesDto,
|
||||||
|
) {
|
||||||
const points = Object.entries(body.values).map(([tag, value]) => ({
|
const points = Object.entries(body.values).map(([tag, value]) => ({
|
||||||
edge,
|
edge,
|
||||||
tag,
|
tag,
|
||||||
value: Number(value),
|
value,
|
||||||
timestamp: body.timestamp,
|
timestamp: body.timestamp,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -10,15 +10,17 @@ type IngestResult = {
|
|||||||
export class IngestService {
|
export class IngestService {
|
||||||
constructor(private readonly db: DbService) {}
|
constructor(private readonly db: DbService) {}
|
||||||
|
|
||||||
/** Пишет одну входящую точку в history и обновляет текущий снимок current для UI/SSE. */
|
/** Пишет числовую точку в history и всегда обновляет текущий снимок current для UI/SSE. */
|
||||||
async ingestPoint(point: IngestPointDto): Promise<IngestResult> {
|
async ingestPoint(point: IngestPointDto): Promise<IngestResult> {
|
||||||
await this.db.query(
|
if (point.value !== null) {
|
||||||
`
|
await this.db.query(
|
||||||
INSERT INTO history (timestamp, edge, tag, value)
|
`
|
||||||
VALUES ($1, $2, $3, $4)
|
INSERT INTO history (timestamp, edge, tag, value)
|
||||||
`,
|
VALUES ($1, $2, $3, $4)
|
||||||
[point.timestamp, point.edge, point.tag, point.value],
|
`,
|
||||||
);
|
[point.timestamp, point.edge, point.tag, point.value],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await this.db.query(
|
await this.db.query(
|
||||||
`
|
`
|
||||||
|
|||||||
@@ -3,17 +3,10 @@ import { NestFactory } from '@nestjs/core';
|
|||||||
import compression from 'compression';
|
import compression from 'compression';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
|
|
||||||
/** Запускает HTTP API со сжатием, CORS и глобальной валидацией DTO. */
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule);
|
const app = await NestFactory.create(AppModule);
|
||||||
app.use(compression());
|
app.use(compression());
|
||||||
|
app.setGlobalPrefix('api');
|
||||||
app.enableCors({
|
|
||||||
origin: process.env.CORS_ALLOWED_ORIGINS,
|
|
||||||
credentials: true,
|
|
||||||
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
|
||||||
allowedHeaders: ['Content-Type', 'Authorization', 'x-api-key'],
|
|
||||||
});
|
|
||||||
|
|
||||||
app.useGlobalPipes(
|
app.useGlobalPipes(
|
||||||
new ValidationPipe({
|
new ValidationPipe({
|
||||||
|
|||||||
Reference in New Issue
Block a user