Remove outdated documentation files for companies and migration guide; implement server session management features including server assignment and session status updates; enhance database schema for servers and server sessions with new fields and validation; add auto-assign functionality for unassigned sessions.
This commit is contained in:
@@ -1,376 +0,0 @@
|
||||
# Компании и филиалы - Документация
|
||||
|
||||
## Обзор
|
||||
|
||||
Добавлена функциональность для управления компаниями и филиалами с поддержкой привязки пользователей к нескольким филиалам.
|
||||
|
||||
## Структура базы данных
|
||||
|
||||
### Таблицы
|
||||
|
||||
#### `companies` - Компании
|
||||
- `id` - UUID, первичный ключ
|
||||
- `name` - Название компании (обязательное)
|
||||
- `description` - Описание компании
|
||||
- `createdAt` - Дата создания
|
||||
- `updatedAt` - Дата обновления
|
||||
|
||||
#### `branches` - Филиалы
|
||||
- `id` - UUID, первичный ключ
|
||||
- `companyId` - UUID, внешний ключ на компанию (каскадное удаление)
|
||||
- `name` - Название филиала (обязательное)
|
||||
- `address` - Адрес филиала
|
||||
- `city` - Город
|
||||
- `country` - Страна
|
||||
- `createdAt` - Дата создания
|
||||
- `updatedAt` - Дата обновления
|
||||
|
||||
#### `user_branches` - Связь пользователей и филиалов (Many-to-Many)
|
||||
- `userId` - UUID, внешний ключ на пользователя (каскадное удаление)
|
||||
- `branchId` - UUID, внешний ключ на филиал (каскадное удаление)
|
||||
- `createdAt` - Дата создания
|
||||
- Составной первичный ключ: `(userId, branchId)`
|
||||
|
||||
### Изменения в таблице `users`
|
||||
Добавлено поле:
|
||||
- `currentBranchId` - UUID, внешний ключ на выбранный филиал (nullable)
|
||||
|
||||
## API Эндпоинты
|
||||
|
||||
Все эндпоинты требуют авторизации через Bearer токен.
|
||||
|
||||
### Компании (`/companies`)
|
||||
|
||||
#### `GET /companies`
|
||||
Получить список всех компаний с их филиалами.
|
||||
|
||||
**Ответ:**
|
||||
```json
|
||||
{
|
||||
"companies": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"name": "Компания 1",
|
||||
"description": "Описание",
|
||||
"branches": [...],
|
||||
"createdAt": "2024-01-01T00:00:00Z",
|
||||
"updatedAt": "2024-01-01T00:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### `GET /companies/:id`
|
||||
Получить компанию по ID.
|
||||
|
||||
**Параметры:**
|
||||
- `id` - UUID компании
|
||||
|
||||
**Ответ:**
|
||||
```json
|
||||
{
|
||||
"company": {
|
||||
"id": "uuid",
|
||||
"name": "Компания 1",
|
||||
"description": "Описание",
|
||||
"branches": [...],
|
||||
"createdAt": "2024-01-01T00:00:00Z",
|
||||
"updatedAt": "2024-01-01T00:00:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### `POST /companies`
|
||||
Создать новую компанию.
|
||||
|
||||
**Body:**
|
||||
```json
|
||||
{
|
||||
"name": "Новая компания",
|
||||
"description": "Описание (опционально)"
|
||||
}
|
||||
```
|
||||
|
||||
**Ответ:**
|
||||
```json
|
||||
{
|
||||
"company": {
|
||||
"id": "uuid",
|
||||
"name": "Новая компания",
|
||||
"description": "Описание",
|
||||
"createdAt": "2024-01-01T00:00:00Z",
|
||||
"updatedAt": "2024-01-01T00:00:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### `PATCH /companies/:id`
|
||||
Обновить компанию.
|
||||
|
||||
**Body:**
|
||||
```json
|
||||
{
|
||||
"name": "Обновленное название (опционально)",
|
||||
"description": "Обновленное описание (опционально)"
|
||||
}
|
||||
```
|
||||
|
||||
#### `DELETE /companies/:id`
|
||||
Удалить компанию (каскадно удалит все филиалы).
|
||||
|
||||
**Ответ:**
|
||||
```json
|
||||
{
|
||||
"message": "Company deleted successfully"
|
||||
}
|
||||
```
|
||||
|
||||
### Филиалы (`/branches`)
|
||||
|
||||
#### `GET /branches/my`
|
||||
Получить филиалы текущего пользователя.
|
||||
|
||||
**Ответ:**
|
||||
```json
|
||||
{
|
||||
"branches": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"companyId": "uuid",
|
||||
"name": "Филиал 1",
|
||||
"address": "Адрес",
|
||||
"city": "Город",
|
||||
"country": "Страна",
|
||||
"company": {...},
|
||||
"createdAt": "2024-01-01T00:00:00Z",
|
||||
"updatedAt": "2024-01-01T00:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### `GET /branches/:id`
|
||||
Получить филиал по ID.
|
||||
|
||||
**Ответ:**
|
||||
```json
|
||||
{
|
||||
"branch": {
|
||||
"id": "uuid",
|
||||
"companyId": "uuid",
|
||||
"name": "Филиал 1",
|
||||
"address": "Адрес",
|
||||
"city": "Город",
|
||||
"country": "Страна",
|
||||
"company": {...},
|
||||
"createdAt": "2024-01-01T00:00:00Z",
|
||||
"updatedAt": "2024-01-01T00:00:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### `GET /branches/:id/users`
|
||||
Получить пользователей филиала.
|
||||
|
||||
**Ответ:**
|
||||
```json
|
||||
{
|
||||
"users": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"email": "user@example.com",
|
||||
"fullName": "Иван Иванов",
|
||||
"role": "manager",
|
||||
"currentBranchId": "uuid"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### `POST /branches`
|
||||
Создать новый филиал.
|
||||
|
||||
**Body:**
|
||||
```json
|
||||
{
|
||||
"companyId": "uuid",
|
||||
"name": "Новый филиал",
|
||||
"address": "Адрес (опционально)",
|
||||
"city": "Город (опционально)",
|
||||
"country": "Страна (опционально)"
|
||||
}
|
||||
```
|
||||
|
||||
#### `PATCH /branches/:id`
|
||||
Обновить филиал.
|
||||
|
||||
**Body:**
|
||||
```json
|
||||
{
|
||||
"name": "Обновленное название (опционально)",
|
||||
"address": "Обновленный адрес (опционально)",
|
||||
"city": "Обновленный город (опционально)",
|
||||
"country": "Обновленная страна (опционально)"
|
||||
}
|
||||
```
|
||||
|
||||
#### `DELETE /branches/:id`
|
||||
Удалить филиал.
|
||||
|
||||
#### `POST /branches/:id/users`
|
||||
Привязать пользователя к филиалу.
|
||||
|
||||
**Body:**
|
||||
```json
|
||||
{
|
||||
"userId": "uuid"
|
||||
}
|
||||
```
|
||||
|
||||
**Ответ:**
|
||||
```json
|
||||
{
|
||||
"message": "User assigned to branch successfully"
|
||||
}
|
||||
```
|
||||
|
||||
#### `DELETE /branches/:id/users/:userId`
|
||||
Отвязать пользователя от филиала.
|
||||
|
||||
**Ответ:**
|
||||
```json
|
||||
{
|
||||
"message": "User removed from branch successfully"
|
||||
}
|
||||
```
|
||||
|
||||
#### `POST /branches/:id/select`
|
||||
Установить филиал как текущий для авторизованного пользователя.
|
||||
|
||||
**Ответ:**
|
||||
```json
|
||||
{
|
||||
"message": "Current branch updated successfully",
|
||||
"currentBranchId": "uuid"
|
||||
}
|
||||
```
|
||||
|
||||
**Ошибки:**
|
||||
- `400` - Пользователь не привязан к этому филиалу
|
||||
|
||||
## Использование
|
||||
|
||||
### Создание компании и филиалов
|
||||
|
||||
```typescript
|
||||
// 1. Создать компанию
|
||||
POST /companies
|
||||
{
|
||||
"name": "ООО Рога и Копыта",
|
||||
"description": "Крупная IT компания"
|
||||
}
|
||||
|
||||
// 2. Создать филиалы
|
||||
POST /branches
|
||||
{
|
||||
"companyId": "company-uuid",
|
||||
"name": "Московский офис",
|
||||
"city": "Москва",
|
||||
"country": "Россия"
|
||||
}
|
||||
|
||||
POST /branches
|
||||
{
|
||||
"companyId": "company-uuid",
|
||||
"name": "Питерский офис",
|
||||
"city": "Санкт-Петербург",
|
||||
"country": "Россия"
|
||||
}
|
||||
```
|
||||
|
||||
### Привязка пользователя к филиалам
|
||||
|
||||
```typescript
|
||||
// Привязать пользователя к московскому офису
|
||||
POST /branches/{moscow-branch-id}/users
|
||||
{
|
||||
"userId": "user-uuid"
|
||||
}
|
||||
|
||||
// Привязать пользователя к питерскому офису
|
||||
POST /branches/{spb-branch-id}/users
|
||||
{
|
||||
"userId": "user-uuid"
|
||||
}
|
||||
```
|
||||
|
||||
### Выбор текущего филиала
|
||||
|
||||
```typescript
|
||||
// Установить московский офис как текущий
|
||||
POST /branches/{moscow-branch-id}/select
|
||||
|
||||
// Теперь в объекте пользователя currentBranchId будет указывать на московский офис
|
||||
```
|
||||
|
||||
### Получение филиалов пользователя
|
||||
|
||||
```typescript
|
||||
// Получить все филиалы, к которым привязан пользователь
|
||||
GET /branches/my
|
||||
```
|
||||
|
||||
## Миграция базы данных
|
||||
|
||||
После добавления схем необходимо выполнить миграцию:
|
||||
|
||||
```bash
|
||||
cd server
|
||||
bun run drizzle-kit generate
|
||||
bun run drizzle-kit migrate
|
||||
```
|
||||
|
||||
## Типы TypeScript
|
||||
|
||||
### Company
|
||||
```typescript
|
||||
type Company = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
```
|
||||
|
||||
### Branch
|
||||
```typescript
|
||||
type Branch = {
|
||||
id: string;
|
||||
companyId: string;
|
||||
name: string;
|
||||
address: string | null;
|
||||
city: string | null;
|
||||
country: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
```
|
||||
|
||||
### UserBranch
|
||||
```typescript
|
||||
type UserBranch = {
|
||||
userId: string;
|
||||
branchId: string;
|
||||
createdAt: Date;
|
||||
};
|
||||
```
|
||||
|
||||
## Примечания
|
||||
|
||||
1. **Каскадное удаление**: При удалении компании автоматически удаляются все её филиалы
|
||||
2. **Каскадное удаление связей**: При удалении пользователя или филиала автоматически удаляются все связи в таблице `user_branches`
|
||||
3. **Валидация**: Пользователь может установить текущим только тот филиал, к которому он привязан
|
||||
4. **Many-to-Many**: Пользователь может быть привязан к нескольким филиалам одновременно
|
||||
5. **Текущий филиал**: Поле `currentBranchId` в таблице пользователей позволяет отслеживать, в каком филиале пользователь работает в данный момент
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
# Руководство по миграции: Упрощение системы ролей
|
||||
|
||||
## Изменения
|
||||
|
||||
Мы удаляем отдельную таблицу `roles` и переносим роли прямо в таблицу `users`.
|
||||
|
||||
### Изменения в схеме:
|
||||
|
||||
1. **Таблица `users`**:
|
||||
- Переименование колонки `role_name` → `role`
|
||||
- Удаление внешнего ключа на таблицу `roles`
|
||||
- Добавление значения по умолчанию `'manager'`
|
||||
|
||||
2. **Таблица `roles`**:
|
||||
- Удаление всей таблицы (больше не нужна)
|
||||
|
||||
## Шаги миграции
|
||||
|
||||
### 1. Создать миграцию через Drizzle Kit
|
||||
|
||||
```bash
|
||||
cd server
|
||||
bun run db:generate
|
||||
```
|
||||
|
||||
### 2. Применить миграцию
|
||||
|
||||
```bash
|
||||
bun run db:migrate
|
||||
```
|
||||
|
||||
### 3. Ручные SQL-команды (если нужно)
|
||||
|
||||
Если автоматическая миграция не сработает, выполните следующие команды вручную:
|
||||
|
||||
```sql
|
||||
-- 1. Удалить внешний ключ из таблицы users
|
||||
ALTER TABLE users DROP CONSTRAINT IF EXISTS users_role_name_roles_name_fk;
|
||||
|
||||
-- 2. Переименовать колонку role_name в role
|
||||
ALTER TABLE users RENAME COLUMN role_name TO role;
|
||||
|
||||
-- 3. Добавить значение по умолчанию
|
||||
ALTER TABLE users ALTER COLUMN role SET DEFAULT 'manager';
|
||||
|
||||
-- 4. Удалить таблицу roles
|
||||
DROP TABLE IF EXISTS roles;
|
||||
```
|
||||
|
||||
## Откат миграции (если нужно)
|
||||
|
||||
Если что-то пошло не так, можно откатить изменения:
|
||||
|
||||
```sql
|
||||
-- 1. Создать таблицу roles заново
|
||||
CREATE TABLE roles (
|
||||
name TEXT PRIMARY KEY,
|
||||
title VARCHAR(100) NOT NULL
|
||||
);
|
||||
|
||||
-- 2. Заполнить таблицу roles
|
||||
INSERT INTO roles (name, title) VALUES
|
||||
('admin', 'Администратор'),
|
||||
('director', 'Директор'),
|
||||
('manager', 'Менеджер');
|
||||
|
||||
-- 3. Переименовать колонку обратно
|
||||
ALTER TABLE users RENAME COLUMN role TO role_name;
|
||||
|
||||
-- 4. Удалить значение по умолчанию
|
||||
ALTER TABLE users ALTER COLUMN role_name DROP DEFAULT;
|
||||
|
||||
-- 5. Добавить внешний ключ
|
||||
ALTER TABLE users ADD CONSTRAINT users_role_name_roles_name_fk
|
||||
FOREIGN KEY (role_name) REFERENCES roles(name);
|
||||
```
|
||||
|
||||
## Проверка
|
||||
|
||||
После миграции проверьте:
|
||||
|
||||
1. Все существующие пользователи сохранили свои роли
|
||||
2. API `/auth/me` возвращает корректный формат:
|
||||
```json
|
||||
{
|
||||
"user": {
|
||||
"id": "...",
|
||||
"email": "...",
|
||||
"fullName": "...",
|
||||
"role": "manager",
|
||||
"createdAt": "..."
|
||||
}
|
||||
}
|
||||
```
|
||||
3. Регистрация и логин работают корректно
|
||||
4. Проверка прав доступа работает (middleware)
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import { authMiddleware } from "../middlewares/auth";
|
||||
import { serverService } from "../services/server";
|
||||
|
||||
export const serverController = new Elysia({ prefix: "/servers" })
|
||||
// POST /servers/register - публичный endpoint для регистрации сервера (без авторизации)
|
||||
.post(
|
||||
"/register",
|
||||
async ({ body, set }) => {
|
||||
const { localIp, hostname, type, gpuFreeMb, branchId, location, tier } =
|
||||
body as {
|
||||
localIp: string;
|
||||
hostname: string;
|
||||
type: "stream" | "local";
|
||||
gpuFreeMb: number;
|
||||
branchId?: string;
|
||||
location?: "ru1" | "uae1";
|
||||
tier?: "demo" | "prod";
|
||||
};
|
||||
|
||||
// Валидация для stream-серверов
|
||||
if (type === "stream") {
|
||||
if (!location) {
|
||||
set.status = 400;
|
||||
return { error: "Location is required for stream servers" };
|
||||
}
|
||||
}
|
||||
|
||||
// Валидация для local-серверов
|
||||
if (type === "local") {
|
||||
if (!branchId) {
|
||||
set.status = 400;
|
||||
return { error: "Branch ID is required for local servers" };
|
||||
}
|
||||
}
|
||||
|
||||
// Установить tier по умолчанию для stream-серверов
|
||||
const finalTier = type === "stream" && !tier ? "demo" : tier;
|
||||
|
||||
// Проверить, существует ли сервер с таким hostname
|
||||
const existingServer = await serverService.findByHostname(hostname);
|
||||
|
||||
if (existingServer) {
|
||||
// Если сервер существует, обновить его информацию
|
||||
const updatedServer = await serverService.update(existingServer.id, {
|
||||
localIp,
|
||||
gpuFreeMb,
|
||||
branchId,
|
||||
location,
|
||||
tier: finalTier,
|
||||
});
|
||||
|
||||
return { server: updatedServer, registered: false };
|
||||
}
|
||||
|
||||
// Создать новый сервер
|
||||
const server = await serverService.create({
|
||||
localIp,
|
||||
hostname,
|
||||
type,
|
||||
gpuFreeMb,
|
||||
branchId,
|
||||
location,
|
||||
tier: finalTier,
|
||||
});
|
||||
|
||||
return { server, registered: true };
|
||||
},
|
||||
{
|
||||
body: t.Object({
|
||||
localIp: t.String({ minLength: 7, maxLength: 45 }),
|
||||
hostname: t.String({ minLength: 1, maxLength: 255 }),
|
||||
type: t.Union([t.Literal("stream"), t.Literal("local")]),
|
||||
gpuFreeMb: t.Number({ minimum: 0 }),
|
||||
branchId: t.Optional(t.String({ format: "uuid" })),
|
||||
location: t.Optional(t.Union([t.Literal("ru1"), t.Literal("uae1")])),
|
||||
tier: t.Optional(t.Union([t.Literal("demo"), t.Literal("prod")])),
|
||||
}),
|
||||
}
|
||||
)
|
||||
// PATCH /servers/:id/gpu - обновить свободную память GPU (публичный endpoint)
|
||||
.patch(
|
||||
"/:id/gpu",
|
||||
async ({ params, body, status }) => {
|
||||
const { id } = params;
|
||||
const { gpuFreeMb } = body as { gpuFreeMb: number };
|
||||
|
||||
// Проверить существование сервера
|
||||
const server = await serverService.findById(id);
|
||||
|
||||
if (!server) {
|
||||
return status(404, "Server not found");
|
||||
}
|
||||
|
||||
const updatedServer = await serverService.updateGpuMemory(id, gpuFreeMb);
|
||||
|
||||
return { server: updatedServer };
|
||||
},
|
||||
{
|
||||
body: t.Object({
|
||||
gpuFreeMb: t.Number({ minimum: 0 }),
|
||||
}),
|
||||
}
|
||||
)
|
||||
// GET /servers/:id/sessions - получить сессии для конкретного сервера (публичный endpoint)
|
||||
.get("/:id/sessions", async ({ params, query, status }) => {
|
||||
const { id } = params;
|
||||
const { statusFilter, mode } = query as {
|
||||
statusFilter?: "starting" | "started" | "ending" | "ended";
|
||||
mode?: "stream" | "local";
|
||||
};
|
||||
|
||||
// Проверить существование сервера
|
||||
const server = await serverService.findById(id);
|
||||
|
||||
if (!server) {
|
||||
return status(404, "Server not found");
|
||||
}
|
||||
|
||||
const { serverSessionService } = await import("../services/serverSession");
|
||||
|
||||
// Получаем только сессии, назначенные этому серверу
|
||||
// Main server автоматически назначает серверы для unassigned сессий
|
||||
const sessions = await serverSessionService.findByServerId(id, {
|
||||
status: statusFilter,
|
||||
mode,
|
||||
});
|
||||
|
||||
return { sessions };
|
||||
})
|
||||
// Все остальные роуты требуют авторизации
|
||||
.use(authMiddleware)
|
||||
// GET /servers - получить список серверов с фильтрацией
|
||||
.get("/", async ({ query }) => {
|
||||
const { type, location, tier, branchId } = query as {
|
||||
type?: "stream" | "local";
|
||||
location?: "ru1" | "uae1";
|
||||
tier?: "demo" | "prod";
|
||||
branchId?: string;
|
||||
};
|
||||
|
||||
const servers = await serverService.findAll({
|
||||
type,
|
||||
location,
|
||||
tier,
|
||||
branchId,
|
||||
});
|
||||
|
||||
return { servers };
|
||||
})
|
||||
// GET /servers/available/stream - получить доступные stream-серверы
|
||||
.get("/available/stream", async ({ query }) => {
|
||||
const { tier } = query as { tier?: "demo" | "prod" };
|
||||
|
||||
const servers = await serverService.findAvailableStreamServers(tier);
|
||||
|
||||
return { servers };
|
||||
})
|
||||
// GET /servers/available/local - получить доступные local-серверы
|
||||
.get("/available/local", async ({ query }) => {
|
||||
const { branchId } = query as { branchId?: string };
|
||||
|
||||
const servers = await serverService.findAvailableLocalServers(branchId);
|
||||
|
||||
return { servers };
|
||||
})
|
||||
// GET /servers/:id - получить сервер по ID
|
||||
.get("/:id", async ({ params, status }) => {
|
||||
const { id } = params;
|
||||
|
||||
const server = await serverService.findById(id);
|
||||
|
||||
if (!server) {
|
||||
return status(404, "Server not found");
|
||||
}
|
||||
|
||||
return { server };
|
||||
})
|
||||
// POST /servers - создать сервер
|
||||
.post(
|
||||
"/",
|
||||
async ({ body, set }) => {
|
||||
const { localIp, hostname, type, gpuFreeMb, branchId, location, tier } =
|
||||
body as {
|
||||
localIp: string;
|
||||
hostname: string;
|
||||
type: "stream" | "local";
|
||||
gpuFreeMb: number;
|
||||
branchId?: string;
|
||||
location?: "ru1" | "uae1";
|
||||
tier?: "demo" | "prod";
|
||||
};
|
||||
|
||||
// Валидация для stream-серверов
|
||||
if (type === "stream") {
|
||||
if (!location) {
|
||||
set.status = 400;
|
||||
return { error: "Location is required for stream servers" };
|
||||
}
|
||||
}
|
||||
|
||||
// Валидация для local-серверов
|
||||
if (type === "local") {
|
||||
if (!branchId) {
|
||||
set.status = 400;
|
||||
return { error: "Branch ID is required for local servers" };
|
||||
}
|
||||
}
|
||||
|
||||
// Установить tier по умолчанию для stream-серверов
|
||||
const finalTier = type === "stream" && !tier ? "demo" : tier;
|
||||
|
||||
const server = await serverService.create({
|
||||
localIp,
|
||||
hostname,
|
||||
type,
|
||||
gpuFreeMb,
|
||||
branchId,
|
||||
location,
|
||||
tier: finalTier,
|
||||
});
|
||||
|
||||
return { server };
|
||||
},
|
||||
{
|
||||
body: t.Object({
|
||||
localIp: t.String({ minLength: 7, maxLength: 45 }),
|
||||
hostname: t.String({ minLength: 1, maxLength: 255 }),
|
||||
type: t.Union([t.Literal("stream"), t.Literal("local")]),
|
||||
gpuFreeMb: t.Number({ minimum: 0 }),
|
||||
branchId: t.Optional(t.String({ format: "uuid" })),
|
||||
location: t.Optional(t.Union([t.Literal("ru1"), t.Literal("uae1")])),
|
||||
tier: t.Optional(t.Union([t.Literal("demo"), t.Literal("prod")])),
|
||||
}),
|
||||
}
|
||||
)
|
||||
// PATCH /servers/:id - обновить сервер
|
||||
.patch(
|
||||
"/:id",
|
||||
async ({ params, body, status, set }) => {
|
||||
const { id } = params;
|
||||
const { localIp, hostname, gpuFreeMb, branchId, location, tier } =
|
||||
body as {
|
||||
localIp?: string;
|
||||
hostname?: string;
|
||||
gpuFreeMb?: number;
|
||||
branchId?: string;
|
||||
location?: "ru1" | "uae1";
|
||||
tier?: "demo" | "prod";
|
||||
};
|
||||
|
||||
// Проверить существование сервера
|
||||
const server = await serverService.findById(id);
|
||||
|
||||
if (!server) {
|
||||
return status(404, "Server not found");
|
||||
}
|
||||
|
||||
// Валидация для stream-серверов: нельзя удалить location
|
||||
if (server.type === "stream") {
|
||||
if (location === undefined && server.location === null) {
|
||||
set.status = 400;
|
||||
return { error: "Location cannot be removed from stream servers" };
|
||||
}
|
||||
}
|
||||
|
||||
// Валидация для local-серверов: нельзя удалить branchId
|
||||
if (server.type === "local") {
|
||||
if (branchId === undefined && server.branchId === null) {
|
||||
set.status = 400;
|
||||
return { error: "Branch ID cannot be removed from local servers" };
|
||||
}
|
||||
}
|
||||
|
||||
const updatedServer = await serverService.update(id, {
|
||||
localIp,
|
||||
hostname,
|
||||
gpuFreeMb,
|
||||
branchId,
|
||||
location,
|
||||
tier,
|
||||
});
|
||||
|
||||
return { server: updatedServer };
|
||||
},
|
||||
{
|
||||
body: t.Object({
|
||||
localIp: t.Optional(t.String({ minLength: 7, maxLength: 45 })),
|
||||
hostname: t.Optional(t.String({ minLength: 1, maxLength: 255 })),
|
||||
gpuFreeMb: t.Optional(t.Number({ minimum: 0 })),
|
||||
branchId: t.Optional(t.String({ format: "uuid" })),
|
||||
location: t.Optional(t.Union([t.Literal("ru1"), t.Literal("uae1")])),
|
||||
tier: t.Optional(t.Union([t.Literal("demo"), t.Literal("prod")])),
|
||||
}),
|
||||
}
|
||||
)
|
||||
// DELETE /servers/:id - удалить сервер
|
||||
.delete("/:id", async ({ params, status }) => {
|
||||
const { id } = params;
|
||||
|
||||
// Проверить существование сервера
|
||||
const server = await serverService.findById(id);
|
||||
|
||||
if (!server) {
|
||||
return status(404, "Server not found");
|
||||
}
|
||||
|
||||
await serverService.delete(id);
|
||||
|
||||
return { message: "Server deleted successfully" };
|
||||
});
|
||||
@@ -6,6 +6,78 @@ import { apps } from "../db/schema/apps";
|
||||
import { serverSessionService } from "../services/serverSession";
|
||||
|
||||
export const sessionController = new Elysia({ prefix: "/sessions" })
|
||||
// PATCH /sessions/:id/status - обновить статус сессии (публичный endpoint для сессионного сервера)
|
||||
.patch(
|
||||
"/:id/status",
|
||||
async ({ params, body, status }) => {
|
||||
const { id } = params;
|
||||
const {
|
||||
status: sessionStatus,
|
||||
appPid,
|
||||
cirrusPid,
|
||||
} = body as {
|
||||
status?: "starting" | "started" | "ending" | "ended";
|
||||
appPid?: number;
|
||||
cirrusPid?: number;
|
||||
};
|
||||
|
||||
// Проверить, что сессия существует
|
||||
const session = await serverSessionService.findById(id);
|
||||
|
||||
if (!session) {
|
||||
return status(404, "Session not found");
|
||||
}
|
||||
|
||||
// Обновить сессию
|
||||
const updatedSession = await serverSessionService.update(id, {
|
||||
status: sessionStatus,
|
||||
appPid,
|
||||
cirrusPid,
|
||||
});
|
||||
|
||||
return { session: updatedSession };
|
||||
},
|
||||
{
|
||||
body: t.Object({
|
||||
status: t.Optional(
|
||||
t.Union([
|
||||
t.Literal("starting"),
|
||||
t.Literal("started"),
|
||||
t.Literal("ending"),
|
||||
t.Literal("ended"),
|
||||
])
|
||||
),
|
||||
appPid: t.Optional(t.Number()),
|
||||
cirrusPid: t.Optional(t.Number()),
|
||||
}),
|
||||
}
|
||||
)
|
||||
// POST /sessions/:id/assign-server - назначить сервер для сессии (публичный endpoint для сессионного сервера)
|
||||
.post(
|
||||
"/:id/assign-server",
|
||||
async ({ params, body, status }) => {
|
||||
const { id } = params;
|
||||
const { requiredGpuMb } = body as { requiredGpuMb?: number };
|
||||
|
||||
try {
|
||||
const updatedSession = await serverSessionService.assignServer(
|
||||
id,
|
||||
requiredGpuMb
|
||||
);
|
||||
return { session: updatedSession };
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
return status(400, error.message);
|
||||
}
|
||||
return status(500, "Failed to assign server");
|
||||
}
|
||||
},
|
||||
{
|
||||
body: t.Object({
|
||||
requiredGpuMb: t.Optional(t.Number({ minimum: 0 })),
|
||||
}),
|
||||
}
|
||||
)
|
||||
// Все роуты требуют авторизации
|
||||
.use(authMiddleware)
|
||||
// GET /sessions - получить список сессий пользователя
|
||||
|
||||
@@ -16,17 +16,17 @@ export const sessionStatusEnum = pgEnum("session_status", [
|
||||
|
||||
export const serverSessions = pgTable("server_sessions", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
serverId: uuid("server_id")
|
||||
.notNull()
|
||||
.references(() => servers.id),
|
||||
serverId: uuid("server_id").references(() => servers.id), // Nullable - для stream сессий назначается динамически
|
||||
appId: uuid("app_id")
|
||||
.notNull()
|
||||
.references(() => apps.id),
|
||||
userId: uuid("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
startAt: timestamp("start_at").defaultNow().notNull(),
|
||||
endAt: timestamp("end_at"), // Default 30 minutes from start_at
|
||||
startAt: timestamp("start_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
endAt: timestamp("end_at", { withTimezone: true }), // Default 30 minutes from start_at
|
||||
appPid: integer("app_pid"),
|
||||
cirrusPid: integer("cirrus_pid"),
|
||||
mode: sessionModeEnum("mode").notNull(), // stream, local
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { pgTable, uuid, varchar, pgEnum, timestamp } from "drizzle-orm/pg-core";
|
||||
import {
|
||||
pgTable,
|
||||
uuid,
|
||||
varchar,
|
||||
pgEnum,
|
||||
timestamp,
|
||||
integer,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { relations } from "drizzle-orm";
|
||||
import { createInsertSchema, createSelectSchema } from "drizzle-zod";
|
||||
import { serverSessions } from "./serverSessions";
|
||||
@@ -14,6 +21,7 @@ export const servers = pgTable("servers", {
|
||||
localIp: varchar("local_ip", { length: 45 }).notNull(), // IPv6 can be up to 45 chars
|
||||
hostname: varchar("hostname").notNull(), // hostname сервера
|
||||
type: serverTypeEnum("type").notNull(), // stream, local
|
||||
gpuFreeMb: integer("gpu_free_mb").notNull(), // свободная память на GPU в мегабайтах
|
||||
branchId: uuid("branch_id").references(() => branches.id), // филиал, на котором находится сервер (nullable для локальных серверов)
|
||||
location: serverLocationEnum("location"), // ru1, uae1 (только для stream)
|
||||
tier: serverTierEnum("tier"), // demo, prod (только для stream)
|
||||
@@ -26,7 +34,20 @@ export const servers = pgTable("servers", {
|
||||
});
|
||||
|
||||
// Zod schemas for validation
|
||||
export const insertServerSchema = createInsertSchema(servers);
|
||||
export const insertServerSchema = createInsertSchema(servers).refine(
|
||||
(data) => {
|
||||
// Если тип "stream", то location и tier обязательны
|
||||
if (data.type === "stream") {
|
||||
return data.location !== undefined && data.location !== null;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: "Location is required for stream servers",
|
||||
path: ["location"],
|
||||
}
|
||||
);
|
||||
|
||||
export const selectServerSchema = createSelectSchema(servers);
|
||||
|
||||
// Relations
|
||||
|
||||
@@ -4,6 +4,8 @@ import { authController } from "./controllers/auth";
|
||||
import { sessionController } from "./controllers/session";
|
||||
import { companyController } from "./controllers/company";
|
||||
import { branchController } from "./controllers/branch";
|
||||
import { serverController } from "./controllers/server";
|
||||
import { serverSessionService } from "./services/serverSession";
|
||||
|
||||
const app = new Elysia();
|
||||
|
||||
@@ -18,9 +20,53 @@ app.use(authController);
|
||||
app.use(sessionController);
|
||||
app.use(companyController);
|
||||
app.use(branchController);
|
||||
app.use(serverController);
|
||||
|
||||
app.listen(3000);
|
||||
|
||||
console.log(
|
||||
`🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}`
|
||||
);
|
||||
|
||||
// Запуск фоновой задачи для автоматического назначения серверов
|
||||
const AUTO_ASSIGN_INTERVAL_MS = parseInt(
|
||||
process.env.AUTO_ASSIGN_INTERVAL_MS || "1000",
|
||||
10
|
||||
); // 1 секунда по умолчанию
|
||||
|
||||
async function autoAssignServersTask() {
|
||||
try {
|
||||
const results = await serverSessionService.autoAssignServers();
|
||||
|
||||
if (results.total > 0) {
|
||||
console.log(
|
||||
`[${new Date().toISOString()}] 🎯 Auto-assign: ${
|
||||
results.assigned
|
||||
} assigned, ${results.failed} failed из ${results.total}`
|
||||
);
|
||||
|
||||
if (results.errors.length > 0) {
|
||||
console.error(
|
||||
`[${new Date().toISOString()}] ❌ Ошибки назначения:`,
|
||||
results.errors
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[${new Date().toISOString()}] ❌ Ошибка auto-assign:`,
|
||||
error instanceof Error ? error.message : error
|
||||
);
|
||||
}
|
||||
|
||||
// Планируем следующий запуск
|
||||
setTimeout(autoAssignServersTask, AUTO_ASSIGN_INTERVAL_MS);
|
||||
}
|
||||
|
||||
// Запускаем через 1 секунду после старта сервера
|
||||
setTimeout(() => {
|
||||
console.log(
|
||||
`[${new Date().toISOString()}] 🤖 Запуск фоновой задачи auto-assign (интервал: ${AUTO_ASSIGN_INTERVAL_MS}ms)`
|
||||
);
|
||||
autoAssignServersTask();
|
||||
}, 1000);
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import db from "../../db";
|
||||
import { servers } from "../../db/schema/servers";
|
||||
|
||||
export type ServerType = "stream" | "local";
|
||||
export type ServerLocation = "ru1" | "uae1";
|
||||
export type ServerTier = "demo" | "prod";
|
||||
|
||||
export interface CreateServerParams {
|
||||
localIp: string;
|
||||
hostname: string;
|
||||
type: ServerType;
|
||||
gpuFreeMb: number;
|
||||
branchId?: string;
|
||||
location?: ServerLocation;
|
||||
tier?: ServerTier;
|
||||
}
|
||||
|
||||
export interface UpdateServerParams {
|
||||
localIp?: string;
|
||||
hostname?: string;
|
||||
gpuFreeMb?: number;
|
||||
branchId?: string;
|
||||
location?: ServerLocation;
|
||||
tier?: ServerTier;
|
||||
}
|
||||
|
||||
export interface FindServersFilters {
|
||||
type?: ServerType;
|
||||
location?: ServerLocation;
|
||||
tier?: ServerTier;
|
||||
branchId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Сервис для работы с серверами
|
||||
*/
|
||||
export const serverService = {
|
||||
/**
|
||||
* Создать сервер
|
||||
*/
|
||||
async create(params: CreateServerParams) {
|
||||
// Валидация для stream-серверов
|
||||
if (params.type === "stream" && !params.location) {
|
||||
throw new Error("Location is required for stream servers");
|
||||
}
|
||||
|
||||
// Установить tier по умолчанию для stream-серверов
|
||||
const tier =
|
||||
params.type === "stream" && !params.tier ? "demo" : params.tier;
|
||||
|
||||
const [server] = await db
|
||||
.insert(servers)
|
||||
.values({
|
||||
localIp: params.localIp,
|
||||
hostname: params.hostname,
|
||||
type: params.type,
|
||||
gpuFreeMb: params.gpuFreeMb,
|
||||
branchId: params.branchId,
|
||||
location: params.location,
|
||||
tier: tier,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return server;
|
||||
},
|
||||
|
||||
/**
|
||||
* Получить все серверы с фильтрацией
|
||||
*/
|
||||
async findAll(filters?: FindServersFilters) {
|
||||
const conditions = [];
|
||||
|
||||
if (filters?.type) {
|
||||
conditions.push(eq(servers.type, filters.type));
|
||||
}
|
||||
|
||||
if (filters?.location) {
|
||||
conditions.push(eq(servers.location, filters.location));
|
||||
}
|
||||
|
||||
if (filters?.tier) {
|
||||
conditions.push(eq(servers.tier, filters.tier));
|
||||
}
|
||||
|
||||
if (filters?.branchId) {
|
||||
conditions.push(eq(servers.branchId, filters.branchId));
|
||||
}
|
||||
|
||||
const allServers = await db.query.servers.findMany({
|
||||
where: conditions.length > 0 ? and(...conditions) : undefined,
|
||||
orderBy: (servers, { asc }) => [asc(servers.hostname)],
|
||||
});
|
||||
|
||||
return allServers;
|
||||
},
|
||||
|
||||
/**
|
||||
* Найти сервер по ID
|
||||
*/
|
||||
async findById(serverId: string) {
|
||||
const server = await db.query.servers.findFirst({
|
||||
where: eq(servers.id, serverId),
|
||||
});
|
||||
|
||||
return server || null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Найти сервер по hostname
|
||||
*/
|
||||
async findByHostname(hostname: string) {
|
||||
const server = await db.query.servers.findFirst({
|
||||
where: eq(servers.hostname, hostname),
|
||||
});
|
||||
|
||||
return server || null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Получить серверы по филиалу
|
||||
*/
|
||||
async findByBranchId(branchId: string) {
|
||||
const branchServers = await db.query.servers.findMany({
|
||||
where: eq(servers.branchId, branchId),
|
||||
orderBy: (servers, { asc }) => [asc(servers.hostname)],
|
||||
});
|
||||
|
||||
return branchServers;
|
||||
},
|
||||
|
||||
/**
|
||||
* Получить доступные stream-серверы
|
||||
*/
|
||||
async findAvailableStreamServers(tier?: ServerTier) {
|
||||
const conditions = [eq(servers.type, "stream")];
|
||||
|
||||
if (tier) {
|
||||
conditions.push(eq(servers.tier, tier));
|
||||
}
|
||||
|
||||
const streamServers = await db.query.servers.findMany({
|
||||
where: and(...conditions),
|
||||
orderBy: (servers, { desc }) => [desc(servers.gpuFreeMb)],
|
||||
});
|
||||
|
||||
return streamServers;
|
||||
},
|
||||
|
||||
/**
|
||||
* Получить доступные local-серверы
|
||||
*/
|
||||
async findAvailableLocalServers(branchId?: string) {
|
||||
const conditions = [eq(servers.type, "local")];
|
||||
|
||||
if (branchId) {
|
||||
conditions.push(eq(servers.branchId, branchId));
|
||||
}
|
||||
|
||||
const localServers = await db.query.servers.findMany({
|
||||
where: and(...conditions),
|
||||
orderBy: (servers, { desc }) => [desc(servers.gpuFreeMb)],
|
||||
});
|
||||
|
||||
return localServers;
|
||||
},
|
||||
|
||||
/**
|
||||
* Обновить сервер
|
||||
*/
|
||||
async update(serverId: string, params: UpdateServerParams) {
|
||||
const updateData: any = {
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
if (params.localIp) {
|
||||
updateData.localIp = params.localIp;
|
||||
}
|
||||
|
||||
if (params.hostname) {
|
||||
updateData.hostname = params.hostname;
|
||||
}
|
||||
|
||||
if (params.gpuFreeMb !== undefined) {
|
||||
updateData.gpuFreeMb = params.gpuFreeMb;
|
||||
}
|
||||
|
||||
if (params.branchId !== undefined) {
|
||||
updateData.branchId = params.branchId;
|
||||
}
|
||||
|
||||
if (params.location !== undefined) {
|
||||
updateData.location = params.location;
|
||||
}
|
||||
|
||||
if (params.tier !== undefined) {
|
||||
updateData.tier = params.tier;
|
||||
}
|
||||
|
||||
const [updatedServer] = await db
|
||||
.update(servers)
|
||||
.set(updateData)
|
||||
.where(eq(servers.id, serverId))
|
||||
.returning();
|
||||
|
||||
return updatedServer;
|
||||
},
|
||||
|
||||
/**
|
||||
* Обновить свободную память GPU
|
||||
*/
|
||||
async updateGpuMemory(serverId: string, gpuFreeMb: number) {
|
||||
const [updatedServer] = await db
|
||||
.update(servers)
|
||||
.set({
|
||||
gpuFreeMb,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(servers.id, serverId))
|
||||
.returning();
|
||||
|
||||
return updatedServer;
|
||||
},
|
||||
|
||||
/**
|
||||
* Удалить сервер
|
||||
*/
|
||||
async delete(serverId: string) {
|
||||
await db.delete(servers).where(eq(servers.id, serverId));
|
||||
},
|
||||
};
|
||||
|
||||
@@ -120,6 +120,80 @@ export const serverSessionService = {
|
||||
return sessions;
|
||||
},
|
||||
|
||||
/**
|
||||
* Получить все сессии для конкретного сервера
|
||||
*/
|
||||
async findByServerId(
|
||||
serverId: string,
|
||||
filters?: {
|
||||
status?: SessionStatus;
|
||||
mode?: SessionMode;
|
||||
}
|
||||
) {
|
||||
const conditions = [eq(serverSessions.serverId, serverId)];
|
||||
|
||||
if (filters?.status) {
|
||||
conditions.push(eq(serverSessions.status, filters.status));
|
||||
}
|
||||
|
||||
if (filters?.mode) {
|
||||
conditions.push(eq(serverSessions.mode, filters.mode));
|
||||
}
|
||||
|
||||
const sessions = await db.query.serverSessions.findMany({
|
||||
where: and(...conditions),
|
||||
with: {
|
||||
app: true,
|
||||
user: {
|
||||
columns: {
|
||||
id: true,
|
||||
email: true,
|
||||
role: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: (serverSessions, { asc }) => [asc(serverSessions.createdAt)],
|
||||
});
|
||||
|
||||
return sessions;
|
||||
},
|
||||
|
||||
/**
|
||||
* Получить сессии без назначенного сервера
|
||||
*/
|
||||
async findUnassignedSessions(filters?: {
|
||||
status?: SessionStatus;
|
||||
mode?: SessionMode;
|
||||
}) {
|
||||
const { isNull } = await import("drizzle-orm");
|
||||
const conditions = [isNull(serverSessions.serverId)];
|
||||
|
||||
if (filters?.status) {
|
||||
conditions.push(eq(serverSessions.status, filters.status));
|
||||
}
|
||||
|
||||
if (filters?.mode) {
|
||||
conditions.push(eq(serverSessions.mode, filters.mode));
|
||||
}
|
||||
|
||||
const sessions = await db.query.serverSessions.findMany({
|
||||
where: and(...conditions),
|
||||
with: {
|
||||
app: true,
|
||||
user: {
|
||||
columns: {
|
||||
id: true,
|
||||
email: true,
|
||||
role: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: (serverSessions, { asc }) => [asc(serverSessions.createdAt)],
|
||||
});
|
||||
|
||||
return sessions;
|
||||
},
|
||||
|
||||
/**
|
||||
* Проверить, есть ли у пользователя активная сессия для данного приложения
|
||||
*/
|
||||
@@ -161,9 +235,10 @@ export const serverSessionService = {
|
||||
async create(params: CreateSessionParams) {
|
||||
const { appId, userId, mode, serverId } = params;
|
||||
|
||||
// Выбрать сервер (если не указан)
|
||||
// Для local-сессий выбираем сервер сразу
|
||||
// Для stream-сессий сервер будет назначен динамически при запуске
|
||||
let selectedServerId = serverId;
|
||||
if (!selectedServerId) {
|
||||
if (mode === "local" && !selectedServerId) {
|
||||
selectedServerId = await this.selectAvailableServer(mode);
|
||||
if (!selectedServerId) {
|
||||
throw new Error(`No available ${mode} servers`);
|
||||
@@ -178,7 +253,7 @@ export const serverSessionService = {
|
||||
const [newSession] = await db
|
||||
.insert(serverSessions)
|
||||
.values({
|
||||
serverId: selectedServerId,
|
||||
serverId: selectedServerId, // Может быть null для stream-сессий
|
||||
appId,
|
||||
userId,
|
||||
mode,
|
||||
@@ -263,4 +338,210 @@ export const serverSessionService = {
|
||||
|
||||
return updatedSession;
|
||||
},
|
||||
|
||||
/**
|
||||
* Назначить сервер для сессии
|
||||
* Выбирает сервер с максимальной свободной GPU памятью
|
||||
*/
|
||||
async assignServer(sessionId: string, requiredGpuMb?: number) {
|
||||
const session = await this.findById(sessionId);
|
||||
|
||||
if (!session) {
|
||||
throw new Error("Session not found");
|
||||
}
|
||||
|
||||
if (session.serverId) {
|
||||
// Сервер уже назначен
|
||||
return session;
|
||||
}
|
||||
|
||||
// Импортируем serverService динамически чтобы избежать циклических зависимостей
|
||||
const { serverService } = await import("../server");
|
||||
|
||||
let selectedServer;
|
||||
|
||||
if (session.mode === "stream") {
|
||||
// Для stream-сессий выбираем сервер с максимальной свободной памятью
|
||||
// Ищем среди всех tier (prod и demo)
|
||||
const availableServers = await serverService.findAvailableStreamServers();
|
||||
|
||||
if (availableServers.length === 0) {
|
||||
throw new Error(
|
||||
"No available stream servers (check that stream servers are registered)"
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[${new Date().toISOString()}] 📊 Найдено ${
|
||||
availableServers.length
|
||||
} stream-серверов:`,
|
||||
availableServers
|
||||
.map((s) => `${s.hostname} (${s.tier}, ${s.gpuFreeMb}MB)`)
|
||||
.join(", ")
|
||||
);
|
||||
|
||||
// Фильтруем серверы по доступной GPU памяти
|
||||
// Требуемая память берется из gpuLimitMb приложения или используются все доступные серверы
|
||||
const memoryOkServers = requiredGpuMb
|
||||
? availableServers.filter((s) => {
|
||||
const hasEnough = s.gpuFreeMb >= requiredGpuMb;
|
||||
if (!hasEnough) {
|
||||
console.log(
|
||||
`[${new Date().toISOString()}] ⚠️ Сервер ${s.id} (${
|
||||
s.hostname
|
||||
}) пропущен по памяти: ${s.gpuFreeMb}MB < ${requiredGpuMb}MB`
|
||||
);
|
||||
}
|
||||
return hasEnough;
|
||||
})
|
||||
: availableServers;
|
||||
|
||||
if (memoryOkServers.length === 0) {
|
||||
const maxAvailable = Math.max(
|
||||
...availableServers.map((s) => s.gpuFreeMb)
|
||||
);
|
||||
throw new Error(
|
||||
`No servers with enough GPU memory (required: ${requiredGpuMb}MB, max available: ${maxAvailable}MB)`
|
||||
);
|
||||
}
|
||||
|
||||
// Проверяем количество активных сессий на каждом сервере
|
||||
// Максимум одновременных сессий на один stream-сервер (по умолчанию 3)
|
||||
const MAX_SESSIONS_PER_SERVER = parseInt(
|
||||
process.env.MAX_SESSIONS_PER_STREAM_SERVER || "3",
|
||||
10
|
||||
);
|
||||
const suitableServers = [];
|
||||
|
||||
for (const server of memoryOkServers) {
|
||||
// Подсчитываем активные сессии (starting или started)
|
||||
const activeSessions = await this.findByServerId(server.id, {});
|
||||
const activeCount = activeSessions.filter(
|
||||
(s) => s.status === "starting" || s.status === "started"
|
||||
).length;
|
||||
|
||||
if (activeCount >= MAX_SESSIONS_PER_SERVER) {
|
||||
console.log(
|
||||
`[${new Date().toISOString()}] ⚠️ Сервер ${server.id} (${
|
||||
server.hostname
|
||||
}) пропущен по загрузке: ${activeCount}/${MAX_SESSIONS_PER_SERVER} активных сессий`
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
`[${new Date().toISOString()}] ✅ Сервер ${server.id} (${
|
||||
server.hostname
|
||||
}) доступен: ${activeCount}/${MAX_SESSIONS_PER_SERVER} активных сессий`
|
||||
);
|
||||
suitableServers.push(server);
|
||||
}
|
||||
}
|
||||
|
||||
if (suitableServers.length === 0) {
|
||||
throw new Error(
|
||||
`No available servers (all servers have ${MAX_SESSIONS_PER_SERVER} or more active sessions)`
|
||||
);
|
||||
}
|
||||
|
||||
// Берем первый сервер (уже отсортирован по убыванию gpuFreeMb)
|
||||
selectedServer = suitableServers[0];
|
||||
|
||||
console.log(
|
||||
`[${new Date().toISOString()}] ✅ Выбран сервер ${selectedServer.id} (${
|
||||
selectedServer.hostname
|
||||
}) с ${
|
||||
selectedServer.gpuFreeMb
|
||||
}MB свободной памяти для сессии ${sessionId} (требуется: ${
|
||||
requiredGpuMb || "не указано"
|
||||
}MB)`
|
||||
);
|
||||
} else {
|
||||
// Для local-сессий используем существующую логику
|
||||
const serverId = await this.selectAvailableServer(session.mode);
|
||||
if (!serverId) {
|
||||
throw new Error("No available local servers");
|
||||
}
|
||||
selectedServer = await serverService.findById(serverId);
|
||||
}
|
||||
|
||||
if (!selectedServer) {
|
||||
throw new Error("Failed to select server");
|
||||
}
|
||||
|
||||
// Назначаем сервер сессии
|
||||
const [updatedSession] = await db
|
||||
.update(serverSessions)
|
||||
.set({
|
||||
serverId: selectedServer.id,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(serverSessions.id, sessionId))
|
||||
.returning();
|
||||
|
||||
return updatedSession;
|
||||
},
|
||||
|
||||
/**
|
||||
* Автоматически назначить серверы для всех unassigned сессий, готовых к запуску
|
||||
* Вызывается периодически main сервером
|
||||
*/
|
||||
async autoAssignServers() {
|
||||
const now = new Date();
|
||||
|
||||
// Находим все unassigned сессии со статусом "starting" и startAt <= now
|
||||
const { isNull } = await import("drizzle-orm");
|
||||
const unassignedSessions = await db.query.serverSessions.findMany({
|
||||
where: and(
|
||||
isNull(serverSessions.serverId),
|
||||
eq(serverSessions.status, "starting")
|
||||
),
|
||||
with: {
|
||||
app: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Фильтруем сессии, у которых уже наступило время запуска
|
||||
const readySessions = unassignedSessions.filter((session) => {
|
||||
const startAt = new Date(session.startAt);
|
||||
return startAt <= now;
|
||||
});
|
||||
|
||||
const results = {
|
||||
total: readySessions.length,
|
||||
assigned: 0,
|
||||
failed: 0,
|
||||
errors: [] as string[],
|
||||
};
|
||||
|
||||
// Назначаем сервер для каждой готовой сессии
|
||||
for (const session of readySessions) {
|
||||
try {
|
||||
const requiredGpuMb = session.app.gpuLimitMb || undefined;
|
||||
console.log(
|
||||
`[${new Date().toISOString()}] 🔍 Назначение сервера для сессии ${
|
||||
session.id
|
||||
} (приложение: ${session.app.name}, требуется GPU: ${
|
||||
requiredGpuMb || "не указано"
|
||||
}MB)`
|
||||
);
|
||||
|
||||
await this.assignServer(session.id, requiredGpuMb);
|
||||
results.assigned++;
|
||||
} catch (error) {
|
||||
results.failed++;
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
results.errors.push(
|
||||
`Session ${session.id} (${session.app.name}): ${errorMsg}`
|
||||
);
|
||||
|
||||
console.error(
|
||||
`[${new Date().toISOString()}] ❌ Не удалось назначить сервер для сессии ${
|
||||
session.id
|
||||
}:`,
|
||||
errorMsg
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
/dist
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
**/*.trace
|
||||
**/*.zip
|
||||
**/*.tar.gz
|
||||
**/*.tgz
|
||||
**/*.log
|
||||
package-lock.json
|
||||
**/*.bun
|
||||
@@ -0,0 +1,397 @@
|
||||
# Session Server
|
||||
|
||||
Сессионный сервер для автоматической регистрации на основном API сервере.
|
||||
|
||||
## Описание
|
||||
|
||||
Session Server - это приложение, которое автоматически регистрируется на основном сервере (`stream.graff.tech`) и периодически обновляет свою информацию. Это позволяет основному серверу знать о доступных сессионных серверах для распределения нагрузки.
|
||||
|
||||
## Возможности
|
||||
|
||||
- 🚀 Автоматическая регистрация при запуске
|
||||
- 🔄 Периодическое обновление информации о сервере (по умолчанию каждые 30 секунд после завершения предыдущего запроса)
|
||||
- 🎮 Автоматическое определение и обновление свободной GPU памяти через nvidia-smi (по умолчанию каждую секунду после завершения предыдущего запроса)
|
||||
- 🎯 Автоматическое управление игровыми сессиями
|
||||
- Запуск приложений для сессий со статусом "starting"
|
||||
- Остановка приложений для сессий со статусом "ending"
|
||||
- Отслеживание PID запущенных процессов
|
||||
- Автоматическое обновление статусов сессий на главном сервере
|
||||
- 🌐 Автоматическое определение локального IP адреса
|
||||
- 💻 Автоматическое определение hostname
|
||||
- ⚙️ Гибкая конфигурация через переменные окружения
|
||||
- 🔁 Автоматические повторные попытки при ошибках
|
||||
- 📝 Подробное логирование
|
||||
- ⚡ Защита от наложения запросов для всех операций (рекурсивный вызов через setTimeout)
|
||||
|
||||
## Установка
|
||||
|
||||
```bash
|
||||
# Установка зависимостей
|
||||
bun install
|
||||
```
|
||||
|
||||
## Конфигурация
|
||||
|
||||
Создайте файл `.env` в корне проекта на основе `.env.example`:
|
||||
|
||||
```bash
|
||||
# URL основного API сервера
|
||||
API_URL=http://localhost:3000
|
||||
|
||||
# Тип сервера: stream или local
|
||||
SERVER_TYPE=stream
|
||||
|
||||
# Расположение сервера (ОБЯЗАТЕЛЬНО для stream серверов)
|
||||
# Возможные значения: ru1, uae1
|
||||
SERVER_LOCATION=ru1
|
||||
|
||||
# Уровень сервера (только для stream серверов)
|
||||
# Возможные значения: demo, prod
|
||||
SERVER_TIER=demo
|
||||
|
||||
# ID филиала (ОБЯЗАТЕЛЬНО для local серверов)
|
||||
BRANCH_ID=00000000-0000-0000-0000-000000000000
|
||||
|
||||
# Локальный IP адрес (опционально, определяется автоматически)
|
||||
# LOCAL_IP=192.168.1.100
|
||||
|
||||
# Hostname сервера (опционально, определяется автоматически)
|
||||
# HOSTNAME=my-session-server
|
||||
|
||||
# Интервал регистрации в миллисекундах (по умолчанию 30000 = 30 секунд)
|
||||
REGISTER_INTERVAL_MS=30000
|
||||
|
||||
# Интервал обновления GPU памяти в миллисекундах (по умолчанию 1000 = 1 секунда)
|
||||
GPU_UPDATE_INTERVAL_MS=1000
|
||||
|
||||
# Интервал проверки сессий в миллисекундах (по умолчанию 5000 = 5 секунд)
|
||||
SESSION_CHECK_INTERVAL_MS=5000
|
||||
```
|
||||
|
||||
### Переменные окружения
|
||||
|
||||
| Переменная | Описание | Обязательна | По умолчанию |
|
||||
|------------|----------|-------------|--------------|
|
||||
| `API_URL` | URL основного API сервера | Нет | `http://localhost:3000` |
|
||||
| `SERVER_TYPE` | Тип сервера (`stream` или `local`) | Нет | `stream` |
|
||||
| `SERVER_LOCATION` | Расположение (`ru1`, `uae1`) - **обязательно для `stream`** | Да (для stream) | - |
|
||||
| `SERVER_TIER` | Уровень (`demo`, `prod`) - только для `stream` | Нет | `demo` (для stream) |
|
||||
| `BRANCH_ID` | ID филиала - **обязательно для `local`** | Да (для local) | - |
|
||||
| `LOCAL_IP` | Локальный IP адрес | Нет | Определяется автоматически |
|
||||
| `HOSTNAME` | Hostname сервера | Нет | Определяется автоматически |
|
||||
| `REGISTER_INTERVAL_MS` | Интервал регистрации в мс | Нет | `30000` |
|
||||
| `GPU_UPDATE_INTERVAL_MS` | Интервал обновления GPU памяти в мс | Нет | `1000` |
|
||||
| `SESSION_CHECK_INTERVAL_MS` | Интервал проверки сессий в мс | Нет | `5000` |
|
||||
|
||||
**Примечание:** Свободная память GPU (`gpuFreeMb`) автоматически определяется через `nvidia-smi` при каждой регистрации и обновляется каждую секунду (или согласно `GPU_UPDATE_INTERVAL_MS`) после завершения предыдущего запроса. Это предотвращает наложение запросов. Если `nvidia-smi` недоступен, сервер завершит работу с ошибкой.
|
||||
|
||||
## Управление сессиями
|
||||
|
||||
Session Server автоматически управляет игровыми сессиями на этом сервере:
|
||||
|
||||
### Как это работает
|
||||
|
||||
1. **Проверка сессий**: Каждую секунду (или согласно `SESSION_CHECK_INTERVAL_MS`) сервер запрашивает у основного API список сессий для этого сервера
|
||||
2. **Запуск приложений**: Для сессий со статусом `starting`:
|
||||
- ⏰ Проверяется время `startAt` - приложение запускается только если это время уже наступило
|
||||
- **Timezone**: Все время хранится в UTC, сравнение корректно работает независимо от часового пояса сервера
|
||||
- Запланированные сессии (с будущим `startAt`) логируются с информацией о времени до запуска
|
||||
- 🎯 **Централизованное назначение сервера** (для stream-сессий):
|
||||
- **Main server** автоматически назначает серверы каждые 5 секунд
|
||||
- Выбирается сервер с максимальной свободной GPU памятью
|
||||
- Учитываются требования приложения к GPU памяти (`gpuLimitMb`)
|
||||
- Session-server просто проверяет, что сессия назначена ему
|
||||
- Запускается соответствующее приложение
|
||||
- Отслеживается PID процесса
|
||||
- Статус сессии обновляется на `started` на главном сервере
|
||||
3. **Остановка приложений**: Для сессий со статусом `ending`:
|
||||
- Используется `taskkill /pid {PID} /T /F` для завершения всего дерева процессов
|
||||
- `/T` - завершает указанный процесс и ВСЕ дочерние процессы
|
||||
- `/F` - принудительное завершение
|
||||
- Решает проблему с UE5 и другими приложениями, создающими дочерние процессы
|
||||
- Статус сессии обновляется на `ended` на главном сервере
|
||||
4. **Автоматическая очистка**: Процессы для неактивных сессий автоматически останавливаются
|
||||
|
||||
### API endpoints для управления сессиями
|
||||
|
||||
Session Server взаимодействует с основным сервером через следующие endpoints:
|
||||
|
||||
- `GET /servers/:id/sessions` - получить список сессий для сервера
|
||||
- `PATCH /sessions/:id/status` - обновить статус сессии (публичный endpoint)
|
||||
|
||||
### Запуск приложений
|
||||
|
||||
Session Server автоматически запускает `.exe` приложения по стандартному пути:
|
||||
|
||||
```
|
||||
C:\apps\{appName}\{appName}.exe
|
||||
```
|
||||
|
||||
Где `{appName}` - это значение поля `name` из таблицы `apps`.
|
||||
|
||||
#### Структура директорий
|
||||
|
||||
Все приложения должны быть размещены в следующей структуре:
|
||||
```
|
||||
C:\apps\
|
||||
├── minecraft\
|
||||
│ └── minecraft.exe
|
||||
├── fortnite\
|
||||
│ └── fortnite.exe
|
||||
└── cyberpunk\
|
||||
└── cyberpunk.exe
|
||||
```
|
||||
|
||||
#### Особенности запуска и остановки
|
||||
|
||||
- ✅ Автоматическая проверка существования exe файла
|
||||
- ✅ Рабочая директория устанавливается в папку приложения (`C:\apps\{appName}\`)
|
||||
- ✅ Окно консоли скрывается (`windowsHide: true`)
|
||||
- ✅ PID процесса отслеживается и передается на main server
|
||||
- ✅ Автоматическое обновление статуса при завершении процесса
|
||||
- ✅ **Корректное завершение дочерних процессов** - использует `taskkill /T` для завершения всего дерева процессов
|
||||
- ✅ Решает проблему с UE5 приложениями, которые создают множественные процессы
|
||||
|
||||
#### Логи запуска
|
||||
|
||||
```
|
||||
[2025-10-06T10:00:00.000Z] 🚀 Запуск приложения "minecraft" для сессии abc-123
|
||||
[2025-10-06T10:00:00.050Z] 📂 Путь к приложению: C:\apps\minecraft\minecraft.exe
|
||||
[2025-10-06T10:00:01.000Z] ✅ Приложение запущено с PID 12345
|
||||
[2025-10-06T10:00:01.100Z] ✅ Статус сессии abc-123 обновлен на "started"
|
||||
```
|
||||
|
||||
#### Обработка ошибок
|
||||
|
||||
Если exe файл не найден:
|
||||
```
|
||||
❌ Файл приложения не найден: C:\apps\minecraft\minecraft.exe. Убедитесь, что приложение установлено.
|
||||
```
|
||||
|
||||
### Примеры логов
|
||||
|
||||
Запланированная сессия:
|
||||
```
|
||||
[2025-10-06T10:00:00.000Z] ⏰ Сессия 123e4567-e89b-12d3-a456-426614174000 (minecraft) запланирована через 120 сек
|
||||
```
|
||||
|
||||
Запуск сессии:
|
||||
```
|
||||
[2025-10-06T10:02:00.000Z] 🚀 Запуск приложения "minecraft" для сессии 123e4567-e89b-12d3-a456-426614174000
|
||||
[2025-10-06T10:02:01.000Z] ✅ Приложение запущено с PID 12345
|
||||
[2025-10-06T10:02:01.100Z] ✅ Статус сессии 123e4567-e89b-12d3-a456-426614174000 обновлен на "started"
|
||||
```
|
||||
|
||||
Остановка сессии:
|
||||
```
|
||||
[2025-10-06T10:32:00.000Z] 🛑 Остановка приложения для сессии 123e4567-e89b-12d3-a456-426614174000 (PID: 12345)
|
||||
[2025-10-06T10:32:00.500Z] ✅ Дерево процессов для PID 12345 успешно завершено
|
||||
[2025-10-06T10:32:00.600Z] ✅ Приложение и все дочерние процессы остановлены для сессии 123e4567-e89b-12d3-a456-426614174000
|
||||
[2025-10-06T10:32:00.700Z] ✅ Статус сессии 123e4567-e89b-12d3-a456-426614174000 обновлен на "ended"
|
||||
```
|
||||
|
||||
## Отладка проблем (Troubleshooting)
|
||||
|
||||
Если приложение запускается и сразу завершается с ошибкой, см. подробное руководство по диагностике:
|
||||
- **[TROUBLESHOOTING.md](./TROUBLESHOOTING.md)**
|
||||
|
||||
### Новые возможности логирования
|
||||
|
||||
После обновления вы увидите:
|
||||
- 🔴 **STDERR логи** для диагностики ошибок приложений
|
||||
- 📊 **Счётчик активных процессов** при запуске/завершении
|
||||
- ⚠️ **Детальная информация** о кодах выхода и сигналах
|
||||
|
||||
Пример логов с ошибкой:
|
||||
```
|
||||
[10:51:56.498Z] 🚀 Запуск приложения "ShishimGorka" для сессии abc (активных процессов: 1)
|
||||
[10:51:56.563Z] ✅ Приложение запущено с PID 84112 (всего активных: 2)
|
||||
[10:51:57.500Z] 🔴 STDERR [abc]: Error: Port 8888 is already in use
|
||||
[10:51:57.894Z] 🛑 Приложение для сессии abc завершилось с кодом 1
|
||||
[10:51:57.894Z] ⚠️ Приложение завершилось с ошибкой! Код выхода: 1
|
||||
```
|
||||
|
||||
## Работа с часовыми поясами (Timezone)
|
||||
|
||||
Session Server корректно работает с разными часовыми поясами:
|
||||
|
||||
### Как это работает
|
||||
|
||||
1. **База данных**: PostgreSQL хранит все timestamp с timezone в UTC
|
||||
2. **API**: Основной сервер возвращает время в формате ISO 8601 с timezone (например, `2025-10-06T10:00:00.000Z`)
|
||||
3. **Session Server**: JavaScript автоматически парсит ISO 8601 строки в UTC
|
||||
4. **Сравнение**: Все сравнения времени происходят в UTC, независимо от локального часового пояса сервера
|
||||
|
||||
### Миграция базы данных
|
||||
|
||||
⚠️ **Важно**: Если вы обновляете существующую систему, необходимо выполнить миграцию для добавления timezone в поля `startAt` и `endAt`:
|
||||
|
||||
```bash
|
||||
# Из директории server/
|
||||
psql -d $DATABASE_URL -f timezone_migration.sql
|
||||
```
|
||||
|
||||
См. `server/TIMEZONE_MIGRATION.md` для подробностей.
|
||||
|
||||
### Примеры
|
||||
|
||||
Если сервер в России создаёт сессию на 14:00 по московскому времени (UTC+3):
|
||||
- В БД сохранится: `2025-10-06T11:00:00.000Z` (UTC)
|
||||
- Сервер в ОАЭ (UTC+4) увидит: 15:00 по местному времени
|
||||
- Session Server запустит приложение ровно в 11:00 UTC, независимо от локального времени
|
||||
|
||||
## Запуск
|
||||
|
||||
### Development режим
|
||||
|
||||
```bash
|
||||
bun run dev
|
||||
```
|
||||
|
||||
### Production режим
|
||||
|
||||
```bash
|
||||
# Собрать проект
|
||||
bun run build
|
||||
|
||||
# Запустить собранное приложение
|
||||
bun run start
|
||||
```
|
||||
|
||||
## Примеры использования
|
||||
|
||||
### Stream сервер в России (демо)
|
||||
|
||||
```env
|
||||
API_URL=https://api.stream.graff.tech
|
||||
SERVER_TYPE=stream
|
||||
SERVER_LOCATION=ru1
|
||||
SERVER_TIER=demo
|
||||
REGISTER_INTERVAL_MS=30000
|
||||
```
|
||||
|
||||
### Stream сервер в ОАЭ (продакшн)
|
||||
|
||||
```env
|
||||
API_URL=https://api.stream.graff.tech
|
||||
SERVER_TYPE=stream
|
||||
SERVER_LOCATION=uae1
|
||||
SERVER_TIER=prod
|
||||
REGISTER_INTERVAL_MS=30000
|
||||
```
|
||||
|
||||
### Local сервер для филиала
|
||||
|
||||
```env
|
||||
API_URL=https://api.stream.graff.tech
|
||||
SERVER_TYPE=local
|
||||
BRANCH_ID=123e4567-e89b-12d3-a456-426614174000
|
||||
REGISTER_INTERVAL_MS=60000
|
||||
```
|
||||
|
||||
## Логирование
|
||||
|
||||
Сервер выводит подробную информацию о своей работе:
|
||||
|
||||
```text
|
||||
============================================================
|
||||
🚀 Запуск сессионного сервера
|
||||
============================================================
|
||||
Конфигурация:
|
||||
API URL: http://localhost:3000
|
||||
Hostname: DESKTOP-ABC123
|
||||
Local IP: 192.168.1.100
|
||||
Type: stream
|
||||
GPU Free MB: 8192 (читается из nvidia-smi)
|
||||
Location: ru1
|
||||
Tier: demo
|
||||
Register Interval: 30000ms
|
||||
GPU Update Interval: 1000ms
|
||||
============================================================
|
||||
[2025-10-06T12:00:00.000Z] Регистрация сервера...
|
||||
Данные: {
|
||||
"localIp": "192.168.1.100",
|
||||
"hostname": "DESKTOP-ABC123",
|
||||
"type": "stream",
|
||||
"gpuFreeMb": 8192,
|
||||
"location": "ru1",
|
||||
"tier": "demo"
|
||||
}
|
||||
[2025-10-06T12:00:00.123Z] ✅ Сервер успешно зарегистрирован
|
||||
ID сервера: 123e4567-e89b-12d3-a456-426614174000
|
||||
[2025-10-06T12:00:01.000Z] 🎮 GPU память обновлена: 8150 MB
|
||||
[2025-10-06T12:00:02.000Z] 🎮 GPU память обновлена: 8120 MB
|
||||
[2025-10-06T12:00:03.000Z] 🎮 GPU память обновлена: 8100 MB
|
||||
```
|
||||
|
||||
## Обработка ошибок
|
||||
|
||||
Сервер автоматически обрабатывает ошибки:
|
||||
|
||||
- **Валидация конфигурации**:
|
||||
- Stream-серверы: обязательно должен быть указан `SERVER_LOCATION`
|
||||
- Local-серверы: обязательно должен быть указан `BRANCH_ID`
|
||||
- При отсутствии обязательных полей сервер завершит работу с критической ошибкой
|
||||
- **Сетевые ошибки**: Автоматические повторные попытки (до 3 раз)
|
||||
- **Таймауты**: 10 секунд на запрос
|
||||
- **Коды ошибок**: Повторная отправка при временных ошибках сервера (408, 429, 500, 502, 503, 504)
|
||||
- **Ошибки GPU**: Если `nvidia-smi` недоступен или возвращает некорректные данные, сервер завершит работу с критической ошибкой
|
||||
- **Критические ошибки**: Подробное логирование с завершением работы
|
||||
|
||||
## Архитектура
|
||||
|
||||
```text
|
||||
┌──────────────────────────────────┐
|
||||
│ Session Server │
|
||||
│ │
|
||||
│ Регистрация (рекурсивно): │
|
||||
│ 1. Определение IP │
|
||||
│ 2. Определение hostname │
|
||||
│ 3. Запрос GPU (nvidia-smi) │
|
||||
│ 4. POST /servers/register │
|
||||
│ 5. setTimeout → повтор (30s) │
|
||||
│ │
|
||||
│ Обновление GPU (рекурсивно): │
|
||||
│ 1. Запрос GPU (nvidia-smi) │
|
||||
│ 2. PATCH /servers/:id/gpu │
|
||||
│ 3. setTimeout → повтор (1s) │
|
||||
└──────────┬───────────────────────┘
|
||||
│
|
||||
│ HTTP Requests
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────┐
|
||||
│ Main API Server │
|
||||
│ │
|
||||
│ 1. Проверка по hostname │
|
||||
│ 2. Создание/обновление │
|
||||
│ 3. Обновление GPU памяти │
|
||||
│ 4. Возврат данных │
|
||||
└──────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Разработка
|
||||
|
||||
### Структура проекта
|
||||
|
||||
```text
|
||||
session-server/
|
||||
├── src/
|
||||
│ └── index.ts # Основной файл приложения
|
||||
├── dist/ # Собранное приложение
|
||||
├── package.json
|
||||
├── tsconfig.json
|
||||
├── bun.build.ts # Конфигурация сборки
|
||||
└── README.md
|
||||
```
|
||||
|
||||
### Требования
|
||||
|
||||
- Bun >= 1.0
|
||||
- Node.js >= 18 (опционально)
|
||||
- TypeScript >= 5.0
|
||||
- **NVIDIA GPU с установленным `nvidia-smi`** (обязательно)
|
||||
|
||||
## Лицензия
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,32 @@
|
||||
import { $, Glob } from "bun";
|
||||
import path from "path";
|
||||
import { existsSync } from "fs";
|
||||
|
||||
// Cross-platform directory removal
|
||||
if (process.platform === "win32") {
|
||||
// await $`cmd /c "rmdir /s /q dist"`;
|
||||
} else {
|
||||
await $`rm -rf ./dist`;
|
||||
}
|
||||
|
||||
// await Bun.build({
|
||||
// entrypoints: ["./src/index.ts"],
|
||||
// env: "inline",
|
||||
// target: "bun",
|
||||
// outdir: `./dist`,
|
||||
// minify: true,
|
||||
// });
|
||||
|
||||
// Build all files in src
|
||||
for (const entrypoint of new Glob("./src/**/*.ts").scanSync()) {
|
||||
const parts = entrypoint.split(path.sep);
|
||||
const entrypointPath = path.join(...parts.slice(2, -1));
|
||||
|
||||
await Bun.build({
|
||||
entrypoints: [entrypoint],
|
||||
target: "bun",
|
||||
outdir: path.join("dist", entrypointPath),
|
||||
env: "inline",
|
||||
minify: true,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "strem.graff.tech-session-server",
|
||||
"dependencies": {
|
||||
"got": "^14.4.9",
|
||||
},
|
||||
"devDependencies": {
|
||||
"bun-types": "latest",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="],
|
||||
|
||||
"@sindresorhus/is": ["@sindresorhus/is@7.1.0", "", {}, "sha512-7F/yz2IphV39hiS2zB4QYVkivrptHHh0K8qJJd9HhuWSdvf8AN7NpebW3CcDZDBQsUPMoDKWsY2WWgW7bqOcfA=="],
|
||||
|
||||
"@szmarczak/http-timer": ["@szmarczak/http-timer@5.0.1", "", { "dependencies": { "defer-to-connect": "^2.0.1" } }, "sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw=="],
|
||||
|
||||
"@types/http-cache-semantics": ["@types/http-cache-semantics@4.0.4", "", {}, "sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA=="],
|
||||
|
||||
"@types/node": ["@types/node@24.7.0", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-IbKooQVqUBrlzWTi79E8Fw78l8k1RNtlDDNWsFZs7XonuQSJ8oNYfEeclhprUldXISRMLzBpILuKgPlIxm+/Yw=="],
|
||||
|
||||
"@types/react": ["@types/react@19.2.0", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-1LOH8xovvsKsCBq1wnT4ntDUdCJKmnEakhsuoUSy6ExlHCkGP2hqnatagYTgFk6oeL0VU31u7SNjunPN+GchtA=="],
|
||||
|
||||
"bun-types": ["bun-types@1.2.23", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-R9f0hKAZXgFU3mlrA0YpE/fiDvwV0FT9rORApt2aQVWSuJDzZOyB5QLc0N/4HF57CS8IXJ6+L5E4W1bW6NS2Aw=="],
|
||||
|
||||
"cacheable-lookup": ["cacheable-lookup@7.0.0", "", {}, "sha512-+qJyx4xiKra8mZrcwhjMRMUhD5NR1R8esPkzIYxX96JiecFoxAXFuz/GpR3+ev4PE1WamHip78wV0vcmPQtp8w=="],
|
||||
|
||||
"cacheable-request": ["cacheable-request@12.0.1", "", { "dependencies": { "@types/http-cache-semantics": "^4.0.4", "get-stream": "^9.0.1", "http-cache-semantics": "^4.1.1", "keyv": "^4.5.4", "mimic-response": "^4.0.0", "normalize-url": "^8.0.1", "responselike": "^3.0.0" } }, "sha512-Yo9wGIQUaAfIbk+qY0X4cDQgCosecfBe3V9NSyeY4qPC2SAkbCS4Xj79VP8WOzitpJUZKc/wsRCYF5ariDIwkg=="],
|
||||
|
||||
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
|
||||
|
||||
"decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="],
|
||||
|
||||
"defer-to-connect": ["defer-to-connect@2.0.1", "", {}, "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg=="],
|
||||
|
||||
"form-data-encoder": ["form-data-encoder@4.1.0", "", {}, "sha512-G6NsmEW15s0Uw9XnCg+33H3ViYRyiM0hMrMhhqQOR8NFc5GhYrI+6I3u7OTw7b91J2g8rtvMBZJDbcGb2YUniw=="],
|
||||
|
||||
"get-stream": ["get-stream@9.0.1", "", { "dependencies": { "@sec-ant/readable-stream": "^0.4.1", "is-stream": "^4.0.1" } }, "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA=="],
|
||||
|
||||
"got": ["got@14.4.9", "", { "dependencies": { "@sindresorhus/is": "^7.0.1", "@szmarczak/http-timer": "^5.0.1", "cacheable-lookup": "^7.0.0", "cacheable-request": "^12.0.1", "decompress-response": "^6.0.0", "form-data-encoder": "^4.0.2", "http2-wrapper": "^2.2.1", "lowercase-keys": "^3.0.0", "p-cancelable": "^4.0.1", "responselike": "^3.0.0", "type-fest": "^4.26.1" } }, "sha512-Dbu075Jwm3QwNCIoCenqkqY8l2gd7e/TanuhMbzZIEsb1mpAneImSusKhZ+XdqqC3S91SDV/1SdWpGXKAlm8tA=="],
|
||||
|
||||
"http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="],
|
||||
|
||||
"http2-wrapper": ["http2-wrapper@2.2.1", "", { "dependencies": { "quick-lru": "^5.1.1", "resolve-alpn": "^1.2.0" } }, "sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ=="],
|
||||
|
||||
"is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="],
|
||||
|
||||
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
|
||||
|
||||
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
|
||||
|
||||
"lowercase-keys": ["lowercase-keys@3.0.0", "", {}, "sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ=="],
|
||||
|
||||
"mimic-response": ["mimic-response@4.0.0", "", {}, "sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg=="],
|
||||
|
||||
"normalize-url": ["normalize-url@8.1.0", "", {}, "sha512-X06Mfd/5aKsRHc0O0J5CUedwnPmnDtLF2+nq+KN9KSDlJHkPuh0JUviWjEWMe0SW/9TDdSLVPuk7L5gGTIA1/w=="],
|
||||
|
||||
"p-cancelable": ["p-cancelable@4.0.1", "", {}, "sha512-wBowNApzd45EIKdO1LaU+LrMBwAcjfPaYtVzV3lmfM3gf8Z4CHZsiIqlM8TZZ8okYvh5A1cP6gTfCRQtwUpaUg=="],
|
||||
|
||||
"quick-lru": ["quick-lru@5.1.1", "", {}, "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA=="],
|
||||
|
||||
"resolve-alpn": ["resolve-alpn@1.2.1", "", {}, "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g=="],
|
||||
|
||||
"responselike": ["responselike@3.0.0", "", { "dependencies": { "lowercase-keys": "^3.0.0" } }, "sha512-40yHxbNcl2+rzXvZuVkrYohathsSJlMTXKryG5y8uciHv1+xDLHQpgjG64JUO9nrEq2jGLH6IZ8BcZyw3wrweg=="],
|
||||
|
||||
"type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
|
||||
|
||||
"undici-types": ["undici-types@7.14.0", "", {}, "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="],
|
||||
|
||||
"decompress-response/mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "strem.graff.tech-session-server",
|
||||
"version": "1.0.50",
|
||||
"module": "src/index.js",
|
||||
"devDependencies": {
|
||||
"bun-types": "latest"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"dev": "bun run --watch src/index.ts",
|
||||
"build": "bun bun.build.ts",
|
||||
"start": "bun ./dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"got": "^14.4.9"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,691 @@
|
||||
import got, { RequestError } from "got";
|
||||
import os from "os";
|
||||
import { execSync, spawn, ChildProcess } from "child_process";
|
||||
import { existsSync } from "fs";
|
||||
|
||||
// Конфигурация
|
||||
const API_URL = process.env.API_URL || "http://localhost:3000";
|
||||
const SERVER_TYPE = (process.env.SERVER_TYPE || "stream") as "stream" | "local";
|
||||
const SERVER_LOCATION =
|
||||
(process.env.SERVER_LOCATION as "ru1" | "uae1" | undefined) || undefined;
|
||||
const SERVER_TIER =
|
||||
(process.env.SERVER_TIER as "demo" | "prod" | undefined) || undefined;
|
||||
const BRANCH_ID = process.env.BRANCH_ID || undefined;
|
||||
const LOCAL_IP = getLocalIp();
|
||||
const HOSTNAME = os.hostname();
|
||||
const REGISTER_INTERVAL_MS = parseInt(
|
||||
process.env.REGISTER_INTERVAL_MS || "30000",
|
||||
10
|
||||
); // 30 секунд по умолчанию
|
||||
const GPU_UPDATE_INTERVAL_MS = parseInt(
|
||||
process.env.GPU_UPDATE_INTERVAL_MS || "1000",
|
||||
10
|
||||
); // 1 секунда по умолчанию
|
||||
const SESSION_CHECK_INTERVAL_MS = parseInt(
|
||||
process.env.SESSION_CHECK_INTERVAL_MS || "1000",
|
||||
10
|
||||
); // 1 секунда по умолчанию
|
||||
|
||||
// ID зарегистрированного сервера (заполняется после регистрации)
|
||||
let SERVER_ID: string | null = null;
|
||||
|
||||
// Карта активных процессов: sessionId -> процесс приложения
|
||||
const activeProcesses = new Map<string, ChildProcess>();
|
||||
|
||||
// Карта сессий в процессе запуска/остановки для предотвращения дублирования
|
||||
const processingSessions = new Set<string>();
|
||||
|
||||
// Карта последнего времени логирования запланированных сессий (для уменьшения спама в логах)
|
||||
const lastScheduledLogTime = new Map<string, number>();
|
||||
|
||||
interface ServerRegistrationData {
|
||||
localIp: string;
|
||||
hostname: string;
|
||||
type: "stream" | "local";
|
||||
gpuFreeMb: number;
|
||||
branchId?: string;
|
||||
location?: "ru1" | "uae1";
|
||||
tier?: "demo" | "prod";
|
||||
}
|
||||
|
||||
interface ServerRegistrationResponse {
|
||||
server: {
|
||||
id: string;
|
||||
localIp: string;
|
||||
hostname: string;
|
||||
type: "stream" | "local";
|
||||
gpuFreeMb: number;
|
||||
branchId?: string;
|
||||
location?: "ru1" | "uae1";
|
||||
tier?: "demo" | "prod";
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
registered: boolean;
|
||||
}
|
||||
|
||||
interface SessionData {
|
||||
id: string;
|
||||
serverId: string | null; // Nullable - для stream сессий назначается динамически
|
||||
appId: string;
|
||||
userId: string;
|
||||
startAt: string;
|
||||
endAt: string | null;
|
||||
appPid: number | null;
|
||||
cirrusPid: number | null;
|
||||
mode: "stream" | "local";
|
||||
status: "starting" | "started" | "ending" | "ended";
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
app: {
|
||||
id: string;
|
||||
name: string;
|
||||
title: string;
|
||||
gpuLimitMb: number | null;
|
||||
psVersion: number | null;
|
||||
};
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
role: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить локальный IP адрес
|
||||
*/
|
||||
function getLocalIp(): string {
|
||||
const interfaces = os.networkInterfaces();
|
||||
|
||||
for (const name of Object.keys(interfaces)) {
|
||||
const netInterface = interfaces[name];
|
||||
if (!netInterface) continue;
|
||||
|
||||
for (const iface of netInterface) {
|
||||
// Пропустить внутренние и non-IPv4 адреса
|
||||
if (iface.family === "IPv4" && !iface.internal) {
|
||||
return iface.address;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "127.0.0.1";
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить свободную память GPU через nvidia-smi
|
||||
* Возвращает количество свободной памяти в МБ
|
||||
* Выбрасывает ошибку, если не удалось получить данные
|
||||
*/
|
||||
function getGpuFreeMb(): number {
|
||||
try {
|
||||
// Выполняем nvidia-smi с форматом вывода только свободной памяти
|
||||
// --query-gpu=memory.free - запрашиваем свободную память
|
||||
// --format=csv,noheader,nounits - CSV формат без заголовков и единиц измерения
|
||||
const output = execSync(
|
||||
"nvidia-smi --query-gpu=memory.free --format=csv,noheader,nounits",
|
||||
{
|
||||
encoding: "utf-8",
|
||||
timeout: 5000, // 5 секунд таймаут
|
||||
}
|
||||
);
|
||||
|
||||
// Парсим вывод (может быть несколько GPU, берём первый)
|
||||
const lines = output.trim().split("\n");
|
||||
if (lines.length > 0 && lines[0]) {
|
||||
const freeMb = parseInt(lines[0].trim(), 10);
|
||||
if (!isNaN(freeMb) && freeMb >= 0) {
|
||||
return freeMb;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Не удалось распарсить вывод nvidia-smi");
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[${new Date().toISOString()}] ❌ Критическая ошибка при получении данных GPU:`,
|
||||
error instanceof Error ? error.message : error
|
||||
);
|
||||
throw new Error(
|
||||
`Невозможно получить данные о GPU через nvidia-smi: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Регистрация сервера на главном сервере
|
||||
*/
|
||||
async function registerServer(isRecursive: boolean = false): Promise<void> {
|
||||
// Валидация для stream-серверов
|
||||
if (SERVER_TYPE === "stream" && !SERVER_LOCATION) {
|
||||
console.error(
|
||||
`[${new Date().toISOString()}] ❌ Ошибка: для stream-серверов обязательно должен быть указан SERVER_LOCATION`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Валидация для local-серверов
|
||||
if (SERVER_TYPE === "local" && !BRANCH_ID) {
|
||||
console.error(
|
||||
`[${new Date().toISOString()}] ❌ Ошибка: для local-серверов обязательно должен быть указан BRANCH_ID`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Установить tier по умолчанию для stream-серверов
|
||||
const tier = SERVER_TYPE === "stream" && !SERVER_TIER ? "demo" : SERVER_TIER;
|
||||
|
||||
// Получаем актуальное значение свободной GPU памяти
|
||||
const gpuFreeMb = getGpuFreeMb();
|
||||
|
||||
const registrationData: ServerRegistrationData = {
|
||||
localIp: LOCAL_IP,
|
||||
hostname: HOSTNAME,
|
||||
type: SERVER_TYPE,
|
||||
gpuFreeMb: gpuFreeMb,
|
||||
branchId: BRANCH_ID,
|
||||
location: SERVER_LOCATION,
|
||||
tier: tier,
|
||||
};
|
||||
|
||||
console.log(`[${new Date().toISOString()}] Регистрация сервера...`);
|
||||
console.log("Данные:", JSON.stringify(registrationData, null, 2));
|
||||
|
||||
try {
|
||||
const response = await got
|
||||
.post(`${API_URL}/servers/register`, {
|
||||
json: registrationData,
|
||||
timeout: {
|
||||
request: 10000, // 10 секунд таймаут
|
||||
},
|
||||
retry: {
|
||||
limit: 3,
|
||||
methods: ["POST"],
|
||||
statusCodes: [408, 413, 429, 500, 502, 503, 504],
|
||||
},
|
||||
})
|
||||
.json<ServerRegistrationResponse>();
|
||||
|
||||
// Сохраняем ID сервера для дальнейших обновлений
|
||||
SERVER_ID = response.server.id;
|
||||
|
||||
if (response.registered) {
|
||||
console.log(
|
||||
`[${new Date().toISOString()}] ✅ Сервер успешно зарегистрирован`
|
||||
);
|
||||
console.log("ID сервера:", response.server.id);
|
||||
} else {
|
||||
console.log(
|
||||
`[${new Date().toISOString()}] 🔄 Информация о сервере обновлена`
|
||||
);
|
||||
}
|
||||
|
||||
// При первом запуске запускаем обновление GPU и проверку сессий после успешной регистрации
|
||||
if (!isRecursive && SERVER_ID) {
|
||||
updateGpuMemory();
|
||||
checkSessions();
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.error(
|
||||
`[${new Date().toISOString()}] ❌ Ошибка регистрации сервера:`,
|
||||
error
|
||||
);
|
||||
|
||||
if (error instanceof RequestError) {
|
||||
console.error("Детали ошибки:", {
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
});
|
||||
} else if (error instanceof Error) {
|
||||
console.error("Ошибка:", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Планируем следующую регистрацию после завершения текущей
|
||||
setTimeout(() => registerServer(true), REGISTER_INTERVAL_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновить информацию о свободной памяти GPU на сервере
|
||||
*/
|
||||
async function updateGpuMemory(): Promise<void> {
|
||||
try {
|
||||
// Получаем актуальное значение свободной GPU памяти
|
||||
const gpuFreeMb = getGpuFreeMb();
|
||||
|
||||
await got.patch(`${API_URL}/servers/${SERVER_ID}/gpu`, {
|
||||
json: { gpuFreeMb },
|
||||
timeout: {
|
||||
request: 5000, // 5 секунд таймаут
|
||||
},
|
||||
retry: {
|
||||
limit: 2,
|
||||
methods: ["PATCH"],
|
||||
statusCodes: [408, 429, 500, 502, 503, 504],
|
||||
},
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[${new Date().toISOString()}] 🎮 GPU память обновлена: ${gpuFreeMb} MB`
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
console.error(
|
||||
`[${new Date().toISOString()}] ❌ Ошибка обновления GPU памяти:`,
|
||||
error instanceof RequestError ? error.message : error
|
||||
);
|
||||
}
|
||||
|
||||
// Планируем следующее обновление после завершения текущего
|
||||
setTimeout(() => updateGpuMemory(), GPU_UPDATE_INTERVAL_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить сессии для этого сервера
|
||||
*/
|
||||
async function fetchSessions(): Promise<SessionData[]> {
|
||||
if (!SERVER_ID) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await got
|
||||
.get(`${API_URL}/servers/${SERVER_ID}/sessions`, {
|
||||
timeout: {
|
||||
request: 5000,
|
||||
},
|
||||
})
|
||||
.json<{ sessions: SessionData[] }>();
|
||||
|
||||
return response.sessions;
|
||||
} catch (error: unknown) {
|
||||
console.error(
|
||||
`[${new Date().toISOString()}] ❌ Ошибка получения сессий:`,
|
||||
error instanceof RequestError ? error.message : error
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновить статус сессии на главном сервере
|
||||
*/
|
||||
async function updateSessionStatus(
|
||||
sessionId: string,
|
||||
status: "starting" | "started" | "ending" | "ended",
|
||||
appPid?: number,
|
||||
cirrusPid?: number
|
||||
): Promise<void> {
|
||||
try {
|
||||
await got.patch(`${API_URL}/sessions/${sessionId}/status`, {
|
||||
json: {
|
||||
status,
|
||||
appPid,
|
||||
cirrusPid,
|
||||
},
|
||||
timeout: {
|
||||
request: 5000,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[${new Date().toISOString()}] ✅ Статус сессии ${sessionId} обновлен на "${status}"`
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
console.error(
|
||||
`[${new Date().toISOString()}] ❌ Ошибка обновления статуса сессии:`,
|
||||
error instanceof RequestError ? error.message : error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Запустить приложение для сессии
|
||||
*/
|
||||
async function startApplication(session: SessionData): Promise<void> {
|
||||
const { id: sessionId, app, serverId } = session;
|
||||
|
||||
// Проверить, не обрабатывается ли уже эта сессия
|
||||
if (processingSessions.has(sessionId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Проверить, не запущено ли уже приложение для этой сессии
|
||||
if (activeProcesses.has(sessionId)) {
|
||||
console.log(
|
||||
`[${new Date().toISOString()}] ⚠️ Приложение для сессии ${sessionId} уже запущено`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Проверить, что сервер назначен
|
||||
// Main server автоматически назначает серверы для готовых к запуску сессий
|
||||
if (!serverId) {
|
||||
console.log(
|
||||
`[${new Date().toISOString()}] ⏳ Сессия ${sessionId} ожидает назначения сервера main сервером`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Проверить, что сессия назначена именно этому серверу
|
||||
if (serverId !== SERVER_ID) {
|
||||
// Это нормально - сессия назначена другому серверу
|
||||
return;
|
||||
}
|
||||
|
||||
processingSessions.add(sessionId);
|
||||
|
||||
try {
|
||||
console.log(
|
||||
`[${new Date().toISOString()}] 🚀 Запуск приложения "${app.name}" для сессии ${sessionId} (активных процессов: ${activeProcesses.size})`
|
||||
);
|
||||
|
||||
// Формируем путь к exe файлу приложения
|
||||
// Путь: C:\apps\{appName}\{appName}.exe
|
||||
const appPath = `C:\\apps\\${app.name}\\${app.name}.exe`;
|
||||
|
||||
console.log(
|
||||
`[${new Date().toISOString()}] 📂 Путь к приложению: ${appPath}`
|
||||
);
|
||||
|
||||
// Проверяем существование exe файла
|
||||
if (!existsSync(appPath)) {
|
||||
throw new Error(
|
||||
`Файл приложения не найден: ${appPath}. Убедитесь, что приложение установлено.`
|
||||
);
|
||||
}
|
||||
|
||||
// Запускаем exe приложение
|
||||
// Используем 'pipe' для stderr чтобы видеть ошибки, но 'ignore' для stdin/stdout
|
||||
const appProcess = spawn(appPath, [], {
|
||||
detached: false,
|
||||
stdio: ["ignore", "ignore", "pipe"], // stdin: ignore, stdout: ignore, stderr: pipe
|
||||
windowsHide: true, // Скрывать окно консоли на Windows
|
||||
cwd: `C:\\apps\\${app.name}`, // Устанавливаем рабочую директорию приложения
|
||||
});
|
||||
|
||||
const appPid = appProcess.pid;
|
||||
|
||||
if (!appPid) {
|
||||
throw new Error("Не удалось получить PID процесса");
|
||||
}
|
||||
|
||||
// Сохранить процесс в карте активных процессов
|
||||
activeProcesses.set(sessionId, appProcess);
|
||||
|
||||
// Логирование stderr для диагностики
|
||||
if (appProcess.stderr) {
|
||||
appProcess.stderr.on("data", (data) => {
|
||||
console.error(
|
||||
`[${new Date().toISOString()}] 🔴 STDERR [${sessionId}]: ${data.toString().trim()}`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Обработка завершения процесса
|
||||
appProcess.on("exit", async (code, signal) => {
|
||||
console.log(
|
||||
`[${new Date().toISOString()}] 🛑 Приложение для сессии ${sessionId} завершилось с кодом ${code}${signal ? ` (сигнал: ${signal})` : ""}`
|
||||
);
|
||||
|
||||
if (code !== 0 && code !== null) {
|
||||
console.error(
|
||||
`[${new Date().toISOString()}] ⚠️ Приложение завершилось с ошибкой! Код выхода: ${code}`
|
||||
);
|
||||
}
|
||||
|
||||
activeProcesses.delete(sessionId);
|
||||
|
||||
// Обновить статус на "ended"
|
||||
await updateSessionStatus(sessionId, "ended");
|
||||
});
|
||||
|
||||
appProcess.on("error", async (error) => {
|
||||
console.error(
|
||||
`[${new Date().toISOString()}] ❌ Ошибка процесса для сессии ${sessionId}:`,
|
||||
error
|
||||
);
|
||||
activeProcesses.delete(sessionId);
|
||||
|
||||
// Обновить статус на "ended" в случае ошибки
|
||||
await updateSessionStatus(sessionId, "ended");
|
||||
});
|
||||
|
||||
// Обновить статус на "started" с PID
|
||||
await updateSessionStatus(sessionId, "started", appPid);
|
||||
|
||||
console.log(
|
||||
`[${new Date().toISOString()}] ✅ Приложение запущено с PID ${appPid} (всего активных: ${activeProcesses.size})`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[${new Date().toISOString()}] ❌ Ошибка запуска приложения:`,
|
||||
error instanceof Error ? error.message : error
|
||||
);
|
||||
// Обновить статус на "ended" в случае ошибки
|
||||
await updateSessionStatus(sessionId, "ended");
|
||||
} finally {
|
||||
processingSessions.delete(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Убить процесс и всё его дерево дочерних процессов (для Windows)
|
||||
*/
|
||||
function killProcessTree(pid: number): void {
|
||||
try {
|
||||
// На Windows используем taskkill с флагом /T для убийства дерева процессов
|
||||
// /F - принудительное завершение
|
||||
// /T - завершить указанный процесс и все дочерние процессы
|
||||
execSync(`taskkill /pid ${pid} /T /F`, {
|
||||
stdio: "ignore",
|
||||
timeout: 10000,
|
||||
});
|
||||
console.log(
|
||||
`[${new Date().toISOString()}] ✅ Дерево процессов для PID ${pid} успешно завершено`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[${new Date().toISOString()}] ⚠️ Ошибка при завершении дерева процессов PID ${pid}:`,
|
||||
error instanceof Error ? error.message : error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Остановить приложение для сессии
|
||||
*/
|
||||
async function stopApplication(session: SessionData): Promise<void> {
|
||||
const { id: sessionId, appPid } = session;
|
||||
|
||||
// Проверить, не обрабатывается ли уже эта сессия
|
||||
if (processingSessions.has(sessionId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const appProcess = activeProcesses.get(sessionId);
|
||||
|
||||
if (!appProcess && !appPid) {
|
||||
console.log(
|
||||
`[${new Date().toISOString()}] ⚠️ Приложение для сессии ${sessionId} не найдено в активных процессах`
|
||||
);
|
||||
// Всё равно обновляем статус на "ended"
|
||||
await updateSessionStatus(sessionId, "ended");
|
||||
return;
|
||||
}
|
||||
|
||||
processingSessions.add(sessionId);
|
||||
|
||||
try {
|
||||
console.log(
|
||||
`[${new Date().toISOString()}] 🛑 Остановка приложения для сессии ${sessionId} (PID: ${appPid || appProcess?.pid || "неизвестен"})`
|
||||
);
|
||||
|
||||
// Используем PID из базы данных если он есть, иначе из процесса
|
||||
const pidToKill = appPid || appProcess?.pid;
|
||||
|
||||
if (pidToKill) {
|
||||
// Убиваем весь процесс и все его дочерние процессы
|
||||
killProcessTree(pidToKill);
|
||||
} else if (appProcess) {
|
||||
// Если по какой-то причине нет PID, пробуем стандартный способ
|
||||
appProcess.kill("SIGTERM");
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
if (!appProcess.killed) {
|
||||
appProcess.kill("SIGKILL");
|
||||
}
|
||||
}
|
||||
|
||||
activeProcesses.delete(sessionId);
|
||||
|
||||
// Обновить статус на "ended"
|
||||
await updateSessionStatus(sessionId, "ended");
|
||||
|
||||
console.log(
|
||||
`[${new Date().toISOString()}] ✅ Приложение и все дочерние процессы остановлены для сессии ${sessionId}`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[${new Date().toISOString()}] ❌ Ошибка остановки приложения:`,
|
||||
error instanceof Error ? error.message : error
|
||||
);
|
||||
} finally {
|
||||
processingSessions.delete(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверить и обработать сессии
|
||||
*/
|
||||
async function checkSessions(): Promise<void> {
|
||||
if (!SERVER_ID) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const sessions = await fetchSessions();
|
||||
// Получаем текущее время в UTC
|
||||
const now = new Date();
|
||||
|
||||
// Обработать сессии со статусом "starting"
|
||||
// Запускать только если время startAt уже наступило
|
||||
// Примечание: PostgreSQL возвращает timestamp with timezone как ISO 8601 строку,
|
||||
// которая автоматически парсится в UTC при создании Date объекта
|
||||
const allStartingSessions = sessions.filter((s) => s.status === "starting");
|
||||
const startingSessions = allStartingSessions.filter((s) => {
|
||||
const startAt = new Date(s.startAt);
|
||||
return startAt <= now;
|
||||
});
|
||||
|
||||
// Логировать запланированные сессии, которые ещё не пришло время запускать
|
||||
// Логируем не чаще раза в 10 секунд для каждой сессии, чтобы не спамить логами
|
||||
const scheduledSessions = allStartingSessions.filter((s) => {
|
||||
const startAt = new Date(s.startAt);
|
||||
return startAt > now;
|
||||
});
|
||||
|
||||
if (scheduledSessions.length > 0) {
|
||||
for (const session of scheduledSessions) {
|
||||
const lastLogTime = lastScheduledLogTime.get(session.id) || 0;
|
||||
const timeSinceLastLog = now.getTime() - lastLogTime;
|
||||
|
||||
// Логируем только если прошло больше 10 секунд с последнего лога
|
||||
if (timeSinceLastLog > 10000) {
|
||||
const startAt = new Date(session.startAt);
|
||||
const timeUntilStart = Math.round((startAt.getTime() - now.getTime()) / 1000);
|
||||
console.log(
|
||||
`[${new Date().toISOString()}] ⏰ Сессия ${session.id} (${session.app.name}) запланирована через ${timeUntilStart} сек`
|
||||
);
|
||||
lastScheduledLogTime.set(session.id, now.getTime());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Очистить карту логирования для сессий, которые больше не запланированы
|
||||
const scheduledSessionIds = new Set(scheduledSessions.map((s) => s.id));
|
||||
for (const sessionId of lastScheduledLogTime.keys()) {
|
||||
if (!scheduledSessionIds.has(sessionId)) {
|
||||
lastScheduledLogTime.delete(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
for (const session of startingSessions) {
|
||||
await startApplication(session);
|
||||
}
|
||||
|
||||
// Обработать сессии со статусом "ending"
|
||||
const endingSessions = sessions.filter((s) => s.status === "ending");
|
||||
for (const session of endingSessions) {
|
||||
await stopApplication(session);
|
||||
}
|
||||
|
||||
// Проверить, что все активные процессы соответствуют активным сессиям
|
||||
// Сессия считается активной, если:
|
||||
// 1. Статус "started"
|
||||
// 2. Статус "starting" И время startAt уже наступило
|
||||
const activeSessions = sessions.filter((s) => {
|
||||
if (s.status === "started") return true;
|
||||
if (s.status === "starting") {
|
||||
const startAt = new Date(s.startAt);
|
||||
return startAt <= now;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
const activeSessionIds = new Set(activeSessions.map((s) => s.id));
|
||||
|
||||
// Остановить процессы для сессий, которые больше не активны
|
||||
for (const [sessionId, process] of activeProcesses.entries()) {
|
||||
if (!activeSessionIds.has(sessionId)) {
|
||||
console.log(
|
||||
`[${new Date().toISOString()}] ⚠️ Найден процесс для неактивной сессии ${sessionId}, остановка`
|
||||
);
|
||||
process.kill("SIGTERM");
|
||||
activeProcesses.delete(sessionId);
|
||||
}
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.error(
|
||||
`[${new Date().toISOString()}] ❌ Ошибка проверки сессий:`,
|
||||
error instanceof Error ? error.message : error
|
||||
);
|
||||
}
|
||||
|
||||
// Планируем следующую проверку
|
||||
setTimeout(() => checkSessions(), SESSION_CHECK_INTERVAL_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Основная функция
|
||||
*/
|
||||
async function main() {
|
||||
console.log("=".repeat(60));
|
||||
console.log("🚀 Запуск сессионного сервера");
|
||||
console.log("=".repeat(60));
|
||||
console.log("Конфигурация:");
|
||||
console.log(` API URL: ${API_URL}`);
|
||||
console.log(` Hostname: ${HOSTNAME}`);
|
||||
console.log(` Local IP: ${LOCAL_IP}`);
|
||||
console.log(` Type: ${SERVER_TYPE}`);
|
||||
console.log(` GPU Free MB: ${getGpuFreeMb()} (читается из nvidia-smi)`);
|
||||
if (SERVER_LOCATION) console.log(` Location: ${SERVER_LOCATION}`);
|
||||
if (SERVER_TIER) console.log(` Tier: ${SERVER_TIER}`);
|
||||
if (BRANCH_ID) console.log(` Branch ID: ${BRANCH_ID}`);
|
||||
console.log(` Register Interval: ${REGISTER_INTERVAL_MS}ms`);
|
||||
console.log(` GPU Update Interval: ${GPU_UPDATE_INTERVAL_MS}ms`);
|
||||
console.log(` Session Check Interval: ${SESSION_CHECK_INTERVAL_MS}ms`);
|
||||
console.log("=".repeat(60));
|
||||
|
||||
// Запуск рекурсивной регистрации
|
||||
// Использует setTimeout вместо setInterval, чтобы избежать наложения запросов
|
||||
// После первой успешной регистрации запускается обновление GPU
|
||||
await registerServer();
|
||||
}
|
||||
|
||||
// Запуск
|
||||
main().catch((error: unknown) => {
|
||||
console.error(
|
||||
"Критическая ошибка:",
|
||||
error instanceof Error ? error.message : error
|
||||
);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
/* Visit https://aka.ms/tsconfig to read more about this file */
|
||||
|
||||
/* Projects */
|
||||
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
|
||||
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
|
||||
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
|
||||
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
|
||||
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
|
||||
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
|
||||
|
||||
/* Language and Environment */
|
||||
"target": "ES2021", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
|
||||
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
|
||||
// "jsx": "preserve", /* Specify what JSX code is generated. */
|
||||
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
|
||||
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
|
||||
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
|
||||
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
|
||||
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
|
||||
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
|
||||
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
|
||||
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
|
||||
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
|
||||
|
||||
/* Modules */
|
||||
"module": "ES2022", /* Specify what module code is generated. */
|
||||
// "rootDir": "./", /* Specify the root folder within your source files. */
|
||||
"moduleResolution": "bundler", /* Specify how TypeScript looks up a file from a given module specifier. */
|
||||
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
|
||||
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
|
||||
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
|
||||
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
|
||||
"types": ["bun-types"], /* Specify type package names to be included without being referenced in a source file. */
|
||||
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
|
||||
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
|
||||
// "resolveJsonModule": true, /* Enable importing .json files. */
|
||||
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
|
||||
|
||||
/* JavaScript Support */
|
||||
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
|
||||
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
|
||||
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
|
||||
|
||||
/* Emit */
|
||||
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
|
||||
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
|
||||
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
|
||||
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
|
||||
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
|
||||
// "outDir": "./", /* Specify an output folder for all emitted files. */
|
||||
// "removeComments": true, /* Disable emitting comments. */
|
||||
// "noEmit": true, /* Disable emitting files from a compilation. */
|
||||
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
|
||||
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */
|
||||
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
|
||||
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
|
||||
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
|
||||
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
|
||||
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
|
||||
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
|
||||
// "newLine": "crlf", /* Set the newline character for emitting files. */
|
||||
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
|
||||
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
|
||||
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
|
||||
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
|
||||
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
|
||||
// "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
|
||||
|
||||
/* Interop Constraints */
|
||||
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
|
||||
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
|
||||
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
|
||||
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
|
||||
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
|
||||
|
||||
/* Type Checking */
|
||||
"strict": true, /* Enable all strict type-checking options. */
|
||||
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
|
||||
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
|
||||
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
|
||||
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
|
||||
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
|
||||
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
|
||||
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
|
||||
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
|
||||
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
|
||||
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
|
||||
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
|
||||
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
|
||||
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
|
||||
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
|
||||
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
|
||||
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
|
||||
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
|
||||
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
|
||||
|
||||
/* Completeness */
|
||||
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
|
||||
"skipLibCheck": true /* Skip type checking all .d.ts files. */
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user