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:
2025-10-06 15:59:55 +05:00
parent 9e4bc7b0f8
commit a49129f643
16 changed files with 2332 additions and 483 deletions
-376
View File
@@ -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` в таблице пользователей позволяет отслеживать, в каком филиале пользователь работает в данный момент
-97
View File
@@ -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)
+311
View File
@@ -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" };
});
+72
View File
@@ -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 - получить список сессий пользователя
+5 -5
View File
@@ -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
+23 -2
View File
@@ -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
+46
View File
@@ -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);
+232
View File
@@ -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));
},
};
+284 -3
View File
@@ -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;
},
};
+44
View File
@@ -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
+397
View File
@@ -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
+32
View File
@@ -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,
});
}
+75
View File
@@ -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=="],
}
}
+17
View File
@@ -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"
}
}
+691
View File
@@ -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);
});
+103
View File
@@ -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. */
}
}