8 Commits

Author SHA1 Message Date
d73df3f3f7 Merge pull request 'BUR-55 Добавлена главная миграция и прописана инструкция по разворачиванию БД.' (#8) from bur-55 into dev
Reviewed-on: #8
2026-07-06 19:57:36 +00:00
Первов Артем
8dba351eb7 BUR-55 Добавлена главная миграция и прописана инструкция по разворачиванию БД. 2026-07-06 22:49:34 +03:00
0859a7a381 Merge pull request 'BUR-61 Изменил чтение поля value в ingest.controller' (#6) from bur-61 into dev
Reviewed-on: #6
2026-07-06 15:55:00 +00:00
Первов Артем
b2d3bbddfc BUR-61 Изменил чтение поля value в ingest.controller 2026-07-06 17:02:35 +03:00
54c8bf2e01 Merge pull request 'BUR-61 current.value сделан nullable в бд. Соответствующие сущности скорректированы под изменение бд' (#5) from bur-61 into dev
Reviewed-on: #5
2026-07-05 19:08:44 +00:00
Первов Артем
22529ba0e8 BUR-61 current.value сделан nullable в бд. Соответствующие сущности скорректированы под изменение бд 2026-07-05 22:03:47 +03:00
387f6c0613 Merge pull request 'BUR-58 Добавлена поддержка работы с камерой' (#4) from bur-58 into dev
Reviewed-on: #4
2026-07-05 04:08:02 +00:00
Первов Артем
203bdf193e BUR-58 Добавлена поддержка работы с камерой 2026-07-05 06:57:54 +03:00
21 changed files with 445 additions and 27 deletions

228
DATABASE_DEPLOYMENT.md Normal file
View File

@@ -0,0 +1,228 @@
# Развертывание базы данных
Инструкция описывает минимальный порядок подготовки PostgreSQL-базы для `drill-cloud-v3`: создать базу, применить схему, зарегистрировать буровую и проверить ingest.
## Требования
- PostgreSQL с расширением TimescaleDB.
- Консольная утилита `psql`.
- Доступ к роли, которая может создавать БД, расширения и таблицы.
- Открытая интерактивная сессия `psql`.
Пример входа в `psql`:
```bash
psql -U postgres -W postgres
```
## 1. Создать базу
Если база уже создана, шаг можно пропустить.
Команды выполняются внутри `psql`:
```sql
SELECT current_database();
CREATE DATABASE "cloud-beta-dev";
\c cloud-beta-dev
SELECT current_database();
```
## 2. Применить базовую миграцию
Базовая схема лежит в:
```text
migrations/0000_cloud_beta_schema.sql
```
Она создает:
- `edge` - справочник буровых;
- `tag` - справочник тегов;
- `current` - последние значения тегов;
- `history` - история числовых значений;
- `camera` - видеопотоки буровых;
- индексы и первичные ключи;
- TimescaleDB hypertable для `history`.
Внутри `psql` применяем файл через `\i`:
```sql
\i 'C:/Users/myart/Drill/cloud/migrations/0000_cloud_beta_schema.sql'
```
Важно: внутри `psql` для Windows-путей используем `/`, а не `\`. Иначе `\Users` будет воспринято как psql-команда.
Проверка:
```sql
\dt public.*
SELECT hypertable_schema, hypertable_name
FROM timescaledb_information.hypertables;
```
Ожидаемо среди таблиц есть `edge`, `tag`, `current`, `history`, `camera`, а `history` отображается как hypertable.
## 3. Зарегистрировать буровую
Минимальная запись в `edge`:
```sql
INSERT INTO edge (id, name, parent_id)
VALUES ('edge-demo', 'Демо буровая', NULL)
ON CONFLICT (id)
DO UPDATE SET
name = EXCLUDED.name,
parent_id = EXCLUDED.parent_id;
```
Проверка:
```sql
SELECT id, name, parent_id
FROM edge
ORDER BY name;
```
## 4. Зарегистрировать теги
Тег должен быть в таблице `tag`, если UI должен показывать русское имя, единицу измерения и метаданные.
Минимальный пример:
```sql
INSERT INTO tag (
id,
name,
min,
max,
comment,
unit_of_measurement,
precision,
tag_group
)
VALUES (
'BN1_10V_ControlVoltage_Fault',
'Ошибка контрольного напряжения 10В',
NULL,
NULL,
'',
'',
0,
'Электрика'
)
ON CONFLICT (id)
DO UPDATE SET
name = EXCLUDED.name,
min = EXCLUDED.min,
max = EXCLUDED.max,
comment = EXCLUDED.comment,
unit_of_measurement = EXCLUDED.unit_of_measurement,
precision = EXCLUDED.precision,
tag_group = EXCLUDED.tag_group;
```
Важно:
- `tag.id` должен совпадать с `tag`, который приходит в ingest.
- `tag.name` используется как основное имя в UI.
- `unit_of_measurement` отображается рядом со значением.
- `min` и `max` используются для статусов/границ, если они заданы.
## 5. Зарегистрировать камеры
Камеры хранятся в `camera`.
Пример:
```sql
INSERT INTO camera (edge, protocol, source)
VALUES ('edge-demo', 'wss', 'beta.video.drill.greact.ru/ws')
ON CONFLICT (edge, protocol, source) DO NOTHING;
```
Проверка:
```sql
SELECT edge, protocol, source
FROM camera
ORDER BY edge, source;
```
## 6. Настроить backend
В `.env` backend-сервиса указать целевую БД:
```env
DATABASE_URL=postgresql://user:password@host:5432/cloud-beta-dev
INGEST_API_KEY=secret-key
PORT=3101
```
Запуск локально:
```bash
npm install
npm run start:dev
```
## 7. Smoke-test ingest
Отправить числовое значение:
```bash
curl -X POST "http://localhost:3101/api/ingest" \
-H "content-type: application/json" \
-H "x-api-key: secret-key" \
-d '{"edge":"edge-demo","tag":"BN1_10V_ControlVoltage_Fault","timestamp":"2026-07-06T10:00:00.000Z","value":0}'
```
Проверить `current`:
```bash
curl "http://localhost:3101/api/current?edge=edge-demo"
```
Проверить БД внутри `psql`:
```sql
SELECT edge, tag, value, "updatedAt"
FROM current
WHERE edge = 'edge-demo';
```
## 8. Поведение `NULL`
Ingest принимает явный `null`:
```json
{
"edge": "edge-demo",
"tag": "BN1_10V_ControlVoltage_Fault",
"timestamp": "2026-07-06T10:00:00.000Z",
"value": null
}
```
Правила:
- `current.value` обновляется в `NULL`;
- в `history` такая точка не пишется;
- на фронтенде current-виджет показывает `NULL` как `—`;
- `0` остается обычным числом и не считается `NULL`.
## 9. Короткая проверка после развертывания
```bash
curl "http://localhost:3101/api/health"
curl "http://localhost:3101/api/edge"
curl "http://localhost:3101/api/current?edge=edge-demo"
curl "http://localhost:3101/api/camera?edge=edge-demo"
```
Если эти запросы отвечают корректно, база готова для работы backend и UI.

View File

@@ -4,27 +4,23 @@ Lightweight Drill Cloud API for an existing PostgreSQL/TimescaleDB database.
## What is intentionally absent ## What is intentionally absent
- No migrations.
- No `tag-translation` module. - No `tag-translation` module.
- No alarm log writes. - No alarm log writes.
- No automatic `edge` or `tag` upserts during ingest. - No automatic `edge` or `tag` upserts during ingest.
## Database ## Database
Database deployment, edge registration and migrations are described in [DATABASE_DEPLOYMENT.md](./DATABASE_DEPLOYMENT.md).
The current `cloud` database schema uses these public tables: The current `cloud` database schema uses these public tables:
- `edge(id, name, parent_id, tag_ids)` - `edge(id, name, parent_id)`
- `tag(id, name, min, max, comment, unit_of_measurement, edge_ids, precision, tag_group)` - `tag(id, name, min, max, comment, unit_of_measurement, precision, tag_group)`
- `current(id, edge, tag, value, createdAt, updatedAt)` - `current(id, edge, tag, value, createdAt, updatedAt)`
- `history(id, edge, timestamp, tag, value, createdAt)` - `history(edge, timestamp, tag, value, createdAt)`
- `camera(edge, protocol, source)`
`history` is expected to be a TimescaleDB hypertable partitioned by `timestamp`. `history` is expected to be a TimescaleDB hypertable partitioned by `timestamp`.
Historical chart ranges use continuous aggregates:
- `history_1m`
- `history_5m`
- `history_1h`
- `history_1d`
## API ## API
@@ -35,6 +31,7 @@ All routes are served under the `/api` prefix.
- `GET /api/tag?edge=edge5&search=pressure` - `GET /api/tag?edge=edge5&search=pressure`
- `GET /api/current?edge=edge5&tags=tag1,tag2` - `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` - `GET /api/history?edge=edge5&tags=tag1,tag2&from=2026-06-01T00:00:00Z&to=2026-06-02T00:00:00Z`
- `GET /api/camera?edge=edge5`
- `POST /api/ingest` - `POST /api/ingest`
- `POST /api/ingest/:edge` - `POST /api/ingest/:edge`
@@ -49,6 +46,8 @@ All routes are served under the `/api` prefix.
} }
``` ```
`value` may be `null`. In that case `current` is updated, but `history` is not.
`POST /api/ingest/:edge` accepts a compact edge payload: `POST /api/ingest/:edge` accepts a compact edge payload:
```json ```json

View File

@@ -0,0 +1,72 @@
-- Base schema copied from cloud-beta.
-- Creates application tables, indexes and TimescaleDB hypertable for history.
CREATE EXTENSION IF NOT EXISTS timescaledb;
CREATE SCHEMA IF NOT EXISTS public;
CREATE TABLE IF NOT EXISTS public.edge (
id varchar(100) NOT NULL,
name text NOT NULL,
parent_id varchar(100),
CONSTRAINT edge_pkey PRIMARY KEY (id)
);
CREATE TABLE IF NOT EXISTS public.tag (
id varchar(100) NOT NULL,
name text NOT NULL,
min double precision,
max double precision,
comment text NOT NULL,
unit_of_measurement text NOT NULL,
precision integer,
tag_group varchar(255),
CONSTRAINT tag_pkey PRIMARY KEY (id)
);
CREATE TABLE IF NOT EXISTS public.current (
id integer GENERATED BY DEFAULT AS IDENTITY,
edge varchar(100) NOT NULL,
tag varchar(100) NOT NULL,
value double precision,
"createdAt" timestamp(3) without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
"updatedAt" timestamp(3) without time zone NOT NULL,
CONSTRAINT current_pkey PRIMARY KEY (id)
);
CREATE TABLE IF NOT EXISTS public.history (
edge varchar(100) NOT NULL,
"timestamp" timestamp(3) without time zone NOT NULL,
tag varchar(100) NOT NULL,
value double precision NOT NULL,
"createdAt" timestamp(3) without time zone DEFAULT now() NOT NULL
);
SELECT create_hypertable(
'public.history',
'timestamp',
chunk_time_interval => INTERVAL '1 day',
if_not_exists => TRUE
);
CREATE TABLE IF NOT EXISTS public.camera (
edge varchar NOT NULL,
protocol varchar NOT NULL,
source varchar NOT NULL,
CONSTRAINT camera_pkey PRIMARY KEY (edge, protocol, source)
);
CREATE UNIQUE INDEX IF NOT EXISTS current_edge_tag_key
ON public.current USING btree (edge, tag);
CREATE INDEX IF NOT EXISTS current_edge_idx
ON public.current USING btree (edge);
CREATE INDEX IF NOT EXISTS current_tag_idx
ON public.current USING btree (tag);
CREATE INDEX IF NOT EXISTS history_edge_tag_timestamp_idx
ON public.history USING btree (edge, tag, "timestamp" DESC);
CREATE INDEX IF NOT EXISTS camera_edge_idx
ON public.camera USING btree (edge);

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

View File

@@ -0,0 +1,2 @@
ALTER TABLE current
ALTER COLUMN value DROP NOT NULL;

View File

@@ -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,

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

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

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

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

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

View File

@@ -0,0 +1,4 @@
export type CameraRow = {
protocol: string;
source: string;
};

View File

@@ -0,0 +1,9 @@
export type CameraItemDto = {
protocol: string;
source: string;
};
export type CameraResponseDto = {
edge: string;
items: CameraItemDto[];
};

View File

@@ -0,0 +1,7 @@
import { IsNotEmpty, IsString } from 'class-validator';
export class GetCamerasDto {
@IsNotEmpty()
@IsString()
edge!: string;
}

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

View File

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

View File

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

View File

@@ -5,5 +5,5 @@ export class IngestEdgeValuesDto {
timestamp!: string; timestamp!: string;
@IsObject() @IsObject()
values!: Record<string, number>; values!: Record<string, number | null>;
} }

View File

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

View File

@@ -22,7 +22,7 @@ export class IngestController {
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,
})); }));

View File

@@ -10,8 +10,9 @@ 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> {
if (point.value !== null) {
await this.db.query( await this.db.query(
` `
INSERT INTO history (timestamp, edge, tag, value) INSERT INTO history (timestamp, edge, tag, value)
@@ -19,6 +20,7 @@ export class IngestService {
`, `,
[point.timestamp, point.edge, point.tag, point.value], [point.timestamp, point.edge, point.tag, point.value],
); );
}
await this.db.query( await this.db.query(
` `