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;
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user